Most mobile apps are still written as if the network is always there. Then a user walks into an elevator, a warehouse, a hospital basement, or a train tunnel, and the app either spins forever or throws away work the user already typed. Offline behaviour is one of the most common things we get called in to fix after launch — and one of the cheapest things to design in up front.
Here is the architecture we reach for on React Native projects in 2026, and the trade-offs behind each layer.
Layer 1: pick the right local store
There is no single answer. Match the store to the shape of the data.
| Store | Good for | Watch out for |
|---|---|---|
| MMKV | Key/value: session tokens, flags, small cached blobs. Synchronous, very fast. | Not a query engine. Do not model relational data in it. |
| expo-sqlite | Real relational data, hundreds to hundreds of thousands of rows. Ships with Expo, supports the async API and web. | You own the schema and migrations. |
| op-sqlite | Same but performance-critical: bulk inserts, large syncs, JSI-level throughput. | Bare/prebuild workflow, extra native surface to maintain. |
| Sync engines (WatermelonDB, PowerSync, Legend-State + a backend) | Multi-device apps where sync itself is the hard part. | You adopt their data model and, often, their backend assumptions. |
A very common and perfectly good answer is MMKV for preferences plus SQLite for domain data. Reach for a full sync engine only when you actually have multi-device concurrent editing — it is a large architectural commitment.
Layer 2: persist your server cache
If you use TanStack Query (and most teams we work with do), you get most of your read-side offline story by persisting the cache. Rehydrate on launch and the app renders real data before the first request resolves.
import { QueryClient } from "@tanstack/react-query";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { MMKV } from "react-native-mmkv";
const storage = new MMKV();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // survive a day offline
staleTime: 1000 * 30,
retry: 2,
},
},
});
const persister = createSyncStoragePersister({
storage: {
getItem: (k) => storage.getString(k) ?? null,
setItem: (k, v) => storage.set(k, v),
removeItem: (k) => storage.delete(k),
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 * 7 }}
>
{children}
</PersistQueryClientProvider>
);
}
Two rules we enforce in review:
- Never persist secrets. Auth tokens belong in the keychain/keystore (
expo-secure-store), not in a serialised query cache. - Version the cache. Pass a
busterstring tied to your app or schema version so a release that changes response shapes discards stale entries instead of rendering them into a crash.
Layer 3: make writes survive the outage
Reads are the easy half. The failure users actually notice is a lost write. The pattern:
- Optimistic update so the UI reflects intent immediately.
- Durable queue on disk so the mutation outlives an app kill.
- Replay when connectivity returns, in order, with idempotency.
const addNote = useMutation({
mutationKey: ["notes", "create"],
mutationFn: (note: DraftNote) => api.createNote(note),
onMutate: async (note) => {
await queryClient.cancelQueries({ queryKey: ["notes"] });
const previous = queryClient.getQueryData<Note[]>(["notes"]);
queryClient.setQueryData<Note[]>(["notes"], (old = []) => [
{ ...note, id: note.clientId, pending: true },
...old,
]);
return { previous };
},
onError: (_err, _note, ctx) => {
if (ctx?.previous) queryClient.setQueryData(["notes"], ctx.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["notes"] }),
});
Generate a client-side id (a UUID) at the moment of creation and send it as an idempotency key. Replayed requests then collapse server-side instead of producing three copies of the same note — the single most common offline bug we find in audits.
For the queue itself, either use TanStack Query's mutation persistence with resumable mutationFn defaults, or keep an explicit outbox table in SQLite (id, endpoint, payload, attempts, created_at) and drain it from a single worker. The explicit outbox is more code but far easier to inspect, retry, and reason about — and it is what we usually recommend for anything transactional.
Layer 4: detect connectivity honestly
@react-native-community/netinfo tells you there is a network. It does not tell you your API is reachable — captive portals and hotel Wi-Fi will happily report "connected". Treat NetInfo as a hint and let the queue drain attempt be the real test:
import NetInfo from "@react-native-community/netinfo";
import { onlineManager } from "@tanstack/react-query";
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) =>
setOnline(Boolean(state.isConnected && state.isInternetReachable !== false)),
),
);
Back off exponentially on repeated failures, and cap attempts so a permanently rejected payload does not spin the battery down.
Layer 5: decide your conflict policy before you need one
Every offline app eventually has two versions of the same record. Pick a policy per entity and write it down:
- Last-write-wins with server timestamps — fine for preferences and low-stakes fields.
- Field-level merge — good for forms where two users touch different fields.
- Append-only / event log — best for anything where history matters; the server folds events rather than overwriting state.
- Ask the user — appropriate for documents and notes; ugly everywhere else.
The wrong answer is "we did not decide", which in practice means silent data loss discovered by a customer.
Tell the user the truth
Offline UX is mostly honesty: a persistent banner when the app is working from cache, a per-item pending indicator, a timestamp of last successful sync, and a manual retry. Users forgive a slow sync. They do not forgive an app that quietly pretended their work was saved.
Testing it
Airplane mode is not enough. In our QA passes we cover: cold launch fully offline, mutation queued then app force-killed, flapping connectivity mid-upload, token expiry while offline, and a schema-changing app update with a populated cache. Each of those has bitten a real client project.
Where we fit
We design and build this layer for teams whose apps are used in the field — logistics, healthcare, construction, retail floor operations — and we retrofit it into apps that shipped online-only and are now getting the support tickets. If that sounds familiar, get in touch.