Back to Tutorials

Build an Offline-First Expo App

Make Firestore-backed screens feel dependable in lifts, tunnels, and bad Wi-Fi with a local cache, optimistic writes, visible sync state, and a recovery path that users can understand.

August 2, 2026 16 min read Paddy B
ExpoReact NativeFirestoreOffline-firstAsyncStorage

The short version

Offline-first is a product decision, not a single Firebase setting. Render the last useful local state immediately, make edits feel instant, queue work that needs the network, and tell the user whether a change is saved, pending, or needs attention.

This guide uses a small task list because the pattern transfers well to notes, field reports, shopping lists, and lightweight project management. It also complements my guide to Firestore rules and offline data, which covers the security and query side in more depth.

1. Choose the right offline architecture

Use three layers with clear responsibilities:

  • Local snapshot: the last list the user can see immediately.
  • Pending mutations: edits waiting to be sent or acknowledged.
  • Firestore listener: the server-backed stream that reconciles the local view.

Do not make a loading spinner the only state between a user and their data. A cached screen with a small “Last synced” label is usually more useful than a blank screen that is technically honest about the network.

Know your SDK boundary

Firestore offline persistence behaves differently across native, web, and JavaScript SDK setups. Treat the local snapshot below as your app-owned fallback. If you need native Firestore persistence and a durable offline write queue, validate the exact Expo development-build setup you plan to ship rather than assuming the web SDK behaves like a native Firebase client.

2. Install the small local layer

Create an Expo app or add the storage package to an existing one. AsyncStorage is useful for the last known snapshot and a small mutation queue; it is not a replacement for Firestore security rules.

npx create-expo-app@latest offline-tasks cd offline-tasks npx expo install @react-native-async-storage/async-storage firebase

Keep Firebase client configuration in your existing module. Public Firebase config can live in the app bundle, but authentication, authorization, and rules still belong on the backend. The Firebase Auth in Expo guide covers that foundation.

3. Define a cacheable task model

Keep cached data serializable and include a small amount of sync metadata. A client-generated ID lets the UI render a new task before Firestore has acknowledged it.

import AsyncStorage from "@react-native-async-storage/async-storage"; export type Task = { id: string; title: string; done: boolean; updatedAt: number; pending?: boolean; }; const tasksKey = (userId: string) => `@offline-tasks/${userId}`; export async function readCachedTasks(userId: string): Promise<Task[]> { const raw = await AsyncStorage.getItem(tasksKey(userId)); return raw ? JSON.parse(raw) : []; } export async function writeCachedTasks(userId: string, tasks: Task[]) { await AsyncStorage.setItem(tasksKey(userId), JSON.stringify(tasks)); }

Store one bounded list per user rather than copying an entire Firestore database into local storage. If a user can have thousands of records, cache the most recent page and add explicit pagination.

4. Hydrate first, then subscribe

Read the local snapshot before attaching the listener. When Firestore emits a newer view, replace the cache and update the screen. The metadata flags help you label cached data without blocking the user.

import { collection, onSnapshot, orderBy, query } from "firebase/firestore"; import { db } from "./firebase"; export function subscribeToTasks( userId: string, onChange: (tasks: Task[], state: "cached" | "synced" | "pending") => void, ) { const tasksQuery = query( collection(db, "users", userId, "tasks"), orderBy("updatedAt", "desc"), ); return onSnapshot(tasksQuery, async (snapshot) => { const tasks = snapshot.docs.map((item) => ({ id: item.id, ...item.data(), updatedAt: item.data().updatedAt?.toMillis?.() ?? Date.now(), })) as Task[]; await writeCachedTasks(userId, tasks); onChange( tasks, snapshot.metadata.hasPendingWrites ? "pending" : snapshot.metadata.fromCache ? "cached" : "synced", ); }); }

Always unsubscribe when the screen unmounts. An active listener per visible screen is easier to reason about than a global listener that quietly survives navigation.

5. Make writes optimistic and recoverable

Update the local list immediately, mark the item as pending, and send the mutation. If the request fails, keep the local edit visible and show a retry action rather than silently throwing the user back to stale data.

import { doc, serverTimestamp, setDoc } from "firebase/firestore"; export async function saveTask(userId: string, task: Task) { const taskRef = doc(db, "users", userId, "tasks", task.id); await setDoc(taskRef, { title: task.title.trim(), done: task.done, updatedAt: serverTimestamp(), }, { merge: true }); }

For edits that must survive a process restart, add a mutation queue with an idempotent operation ID. On reconnect, flush oldest-first, remove successful operations, and retain failures with a human-readable error. The queue should be safe to run twice because mobile apps can be interrupted between the network response and local cleanup.

type PendingMutation = { id: string; // stable operation ID kind: "save-task"; task: Task; attempts: number; }; // Pseudocode for a safe retry loop: for (const mutation of await readQueue()) { try { await saveTask(userId, mutation.task); await removeFromQueue(mutation.id); } catch { await incrementAttempts(mutation.id); break; // retry in order after the next reconnect } }

6. Give sync a useful UI

Use plain language and keep the state close to the data it describes:

  • Saved: the server has acknowledged the current view.
  • Saving: a write is in flight or locally pending.
  • Offline: the app is showing cached data and will retry.
  • Needs attention: a mutation failed and needs a retry or conflict decision.
<Text accessibilityRole="status"> {syncState === "synced" && "Saved"} {syncState === "cached" && "Offline · showing the last saved copy"} {syncState === "pending" && "Saving…"} {syncState === "error" && "Could not sync · Tap to retry"} </Text>

Avoid a permanent green “online” badge. The important question is not only whether the device has a connection; it is whether this particular change has reached the server.

7. Keep Firestore rules authoritative

Optimistic UI is not permission. Every queued write still has to pass Firestore rules, and the server may reject an edit because the user signed out, their role changed, or the record was deleted elsewhere.

match /users/{userId}/tasks/{taskId} { allow read, create, update, delete: if request.auth != null && request.auth.uid == userId && request.resource.data.title is string && request.resource.data.title.size() > 0; }

For updates, validate the fields that are allowed to change and compare resource.data with request.resource.data when ownership or immutable fields matter. Test signed-out, wrong-user, stale-document, and malformed-write cases in the Emulator Suite.

8. Test the uncomfortable moments

  1. Open the task list, enable airplane mode, and confirm the last snapshot still renders.
  2. Create and edit a task offline. Kill the app before reconnecting, then relaunch it.
  3. Restore the network and verify the queue drains without duplicate documents.
  4. Change the same task on two devices and decide whether last-write-wins is acceptable.
  5. Sign out with pending work and confirm it is not sent under the next user account.
  6. Reject a write with Firestore rules and show a retry or conflict state.

The production checklist

  • Bound the cache and clear user data on sign-out.
  • Use stable operation IDs for queued writes.
  • Prefer server timestamps for ordering shared records.
  • Show pending changes without pretending they are synced.
  • Log queue age, retry count, and rejected writes in development.