Skip to content
OTFotf
All posts

Offline-first mutation queue keeps Expo apps working when the network drops

D
DaveAuthor
7 min read
Offline-first mutation queue keeps Expo apps working when the network drops

Mobile networks lie. A request that works on office wifi stalls in a tunnel, times out in an elevator, and half-completes on a congested cell tower. If your Expo app fires mutations directly at the API and hopes for the best, every one of those moments becomes lost user data or a duplicated order. The fix is an offline-first mutation queue: an outbox that persists every write locally, retries it in order, and survives app restarts.

This pattern borrows from email clients and message brokers. The UI never talks to the network directly. It appends an intent to a durable outbox, renders the optimistic result immediately, and a background processor drains the outbox when connectivity allows. The app feels instant on good networks and stays correct on bad ones.

Design the outbox as the source of truth

The outbox is a local table where each row is one pending mutation: an id, the operation type, the payload, a retry count, a status, and a creation timestamp. Every user action that changes server state — creating a record, uploading a photo, toggling a setting — writes a row here first. Nothing else in the app is allowed to call the mutation endpoint directly.

Ordering matters. Draining the queue strictly first-in-first-out keeps dependent operations correct: the profile update must land before the avatar upload that references it. Give each entry a sequence number at insert time and process them in that order. If entry three fails, entries four and five wait. This serial discipline is what separates a queue from a pile of fire-and-forget requests.

Keep the outbox schema explicit with a small status machine: pending, in-flight, failed, and dead. In-flight rows carry a lease timestamp so a crash mid-request does not strand them forever — on startup, anything still marked in-flight past its lease returns to pending. Failed rows with retries remaining go back to the tail; rows that exhaust retries become dead and surface to the user for a decision instead of vanishing silently.

Persist the queue so restarts lose nothing

An in-memory queue is barely better than no queue. Users kill apps, phones die, and the operating system reclaims background work aggressively on both iOS and Android. The outbox must live in durable local storage: SQLite through Expo SQLite, MMKV, or AsyncStorage for small payloads.

SQLite is the right default for a production outbox. It gives you transactions, so the "write the row and update the optimistic UI" step is atomic — you never show a message as sent without a queued row backing it, or vice versa. MMKV is faster for simple key-value state but leaves ordering and crash recovery to you.AsyncStorage works for prototypes but its asynchronous batching can reorder writes under pressure, which is exactly what a queue must never do.

Pair persistence with query caching so reads survive offline too. If your data layer caches server state locally, the app opens to a useful screen with a "syncing" indicator instead of a spinner or an error. The established way to persist a query cache in React Native is the TanStack async-storage persister, which rehydrates cached queries from local storage on launch. Reads come from cache, writes go through the outbox, and the two meet when the network returns.

One codebase. iOS, Android, and web.

The Fitness Kit ships with auth, a database, and a backend already connected — no setup. Live demo at fitness-preview.otf-kit.dev.

See the live demo

Retry with backoff, idempotency, and a dead-letter path

Retrying immediately in a tight loop is how a flaky network turns into a drained battery and a rate-limited account. Each failed attempt should wait longer than the last: one second, then two, then four, capped around a minute, with jitter so a fleet of devices reconnecting at once does not thundering-herd your backend. Reset the backoff the moment an attempt succeeds.

Retries are only safe if the server tolerates duplicates, because "did the request fail before or after the server applied it" is unknowable from the client. Attach a client-generated idempotency key — the outbox row id works perfectly — and have the server deduplicate on it. Without this, every retried payment or retried record creation is a potential double-charge or duplicate row. This is the single most skipped step in homegrown queues, and the one that causes the worst production incidents.

Some failures should never retry. A 401 means re-authenticate, not resend; a 422 means the payload is invalid and will stay invalid. Classify errors before scheduling the next attempt: retry timeouts, network errors, and 5xx responses; pause the queue on authentication failures until the session refreshes (see how Supabase Auth sessions stay signed in when storage and refresh are wired right); and move validation errors straight to the dead-letter state with the server message attached so the user can fix the input.

Reconcile optimistic UI with server reality

The outbox pattern only feels good if the UI responds instantly. When the user taps send, insert the queued row and render the optimistic version in the same transaction. Tag optimistic items visually — a subtle pending indicator — so users learn the difference between "saved on your phone" and "confirmed by the server."

Then handle the three outcomes. On success, replace the optimistic item with the server response, which may include a real id or server-computed fields. On retryable failure, keep the optimistic item in place with its pending state; the user did nothing wrong and should change nothing. On permanent failure, mark the item clearly and offer retry or discard. Never silently drop an item the user believed was saved — that betrayal destroys trust faster than any loading spinner.

Conflict resolution deserves an explicit decision before launch. If the same record changed on the server while the mutation sat queued, who wins? Last-write-wins is simplest and fine for single-user data like drafts and settings. For shared data like comments or inventory, prefer server-wins with a user-facing notice, or merge field-by-field when the domain allows it. Whatever you choose, make it a documented product decision rather than an accident discovered in a bug report.

Test the queue the way networks actually fail

Unit tests with a mocked fetch that always succeeds prove nothing about an offline queue. The tests that matter simulate the ugly middle: a request that succeeds after three timeouts, connectivity that drops mid-drain, an app kill between writing the row and sending the request, and a server that returns 500 for exactly one operation type.

Build a small chaos harness around your queue processor. A test double for the network layer with scripted failure sequences — fail twice then succeed, drop the connection for ten seconds, return a 401 once — exercises the retry, backoff, lease-recovery, and dead-letter paths deterministically. Run the "kill and restart with a non-empty outbox" test on a real device too, because storage behavior under process death is where simulators and real phones diverge most.

Measure the queue in production as well. Track outbox depth over time, time-from-enqueue-to-acknowledged, retry counts per operation type, and dead-letter volume. A growing average depth means your drain rate lags behind user activity — either the network assumptions are wrong or one operation type is failing persistently and blocking everything behind it. Per-operation-type metrics distinguish those two cases instantly.

Ship it in stages, not all at once

Rolling out an offline-first queue across every mutation at once is how you trade known bugs for unknown ones. Start with the highest-value, lowest-risk writes: drafts, favorites, read receipts, analytics events. These tolerate reordering and duplication, so they forgive mistakes in your first implementation while the queue proves itself under real network conditions.

Next, extend to uploads and media, where the payoff is largest because large payloads fail most often on mobile networks. Chunked or resumable uploads pair naturally with the outbox — each chunk becomes a queue entry — and the same retry machinery applies. The patterns in Supabase Storage uploads that survive real mobile networks complement this queue directly.

Only then bring in money and identity: payments, bookings, account changes. These demand idempotency keys, strict ordering, and the dead-letter UX to be fully working, because the cost of a duplicate is real. By this stage the queue, the retry policy, and the monitoring are all battle-tested on forgiving operations, so the critical writes inherit a proven system instead of debuting alongside it.

Sources

react-nativecross-platformsupabase
OTF Fitness Kit

Stop wiring. Start shipping.

  • Login, database, and backend already connected — nothing to set up
  • iOS + Android + web from one codebase
  • AI configs pre-tuned + 40+ tested prompts included