Skip to main content
Back to Blog
9 min read

Offline-first React Native: how we kept seat selection smooth on a 3G train

The architecture that made a 12K-seat picker feel instant with 0 bars of signal. State sync, optimistic writes, the queue that survives app kills, and the edge cases nobody warns you about.

react-nativeofflinemobile

The brief was simple: users buy event tickets on their phone, often in transit to the event itself. The constraint was less simple: the typical buyer was on a commuter train with spotty 3G, switching between Wi-Fi and cellular every few stops, occasionally going fully offline in a tunnel.

A seat picker with a network request per tap was not going to survive that environment. Here's the architecture we built for it, the sync protocol that holds it together, and the edge cases that only surface after a few thousand users.

The failure mode we started from

Our first version was "normal": load seat map, tap seat, POST /reserve, show "reserved" or "failed." Fine on fast Wi-Fi, catastrophic on a train.

What actually happened:

The abandonment rate on the seat picker was 18% on cellular, 2% on Wi-Fi. We needed that first number under 5%.

The rewrite — offline-first from the ground up

The mental shift: the phone is the source of truth for what the user did. The server is the source of truth for what's available. These are two different things, and we stopped conflating them.

The architecture, simplified:

  1. On app start (or event open): fetch full seat availability, cache in SQLite.
  2. On seat tap: update local state immediately, enqueue a sync operation, show a spinner only on explicit action (checkout).
  3. On network available: drain the queue in order, reconcile with server response.
  4. On conflict (server says seat was already taken): show user the conflict with a retry/alternative UI, don't silently drop.
  5. On app kill with pending operations: queue persists to disk, resumes on next launch.

The user-facing feel: every tap is instant. Every screen is navigable. The app only "asks the network" at checkout, and even then the user sees progress on a familiar "preparing your order" screen instead of a 4-second blank.

The queue — the hardest part

A sync queue sounds simple. It is not, because you're implementing a small distributed system whose nodes are a phone and a server that may disagree about reality at any moment.

The requirements we ended up with:

  1. Persistent — survive app kills, survive phone restarts, survive OS upgrades.
  2. Ordered — operations replay in the order the user did them.
  3. Idempotent — a retry doesn't duplicate the action.
  4. Conflict-aware — if the server says "no," the user hears about it.
  5. Observable — we can see what's queued, what's retrying, what failed.

Stack: react-native-mmkv for the fast read/write layer, a JSON blob per operation, a background service that drains the queue whenever NetInfo reports connectivity.

// simplified
type QueueOp =
  | { id: string; type: 'reserve'; seatId: string; clientTs: number }
  | { id: string; type: 'release'; seatId: string; clientTs: number }
  | { id: string; type: 'checkout'; cartId: string; clientTs: number }

// Every op gets a client-generated UUID. Server uses it for idempotency.

The server's API accepts the client-generated UUID as an idempotency key. Duplicate requests (from retries) return the original response without re-processing. This is the same pattern I use for Stripe webhooks — same problem, same fix.

The sync protocol — what the server sends back

The naive protocol is "200 OK" or "409 Conflict." We needed more.

Each sync response carries:

The client:

  1. On ok, marks the op as committed, removes from queue.
  2. On conflict (seat was taken by someone else before our op arrived), marks op as failed, surfaces to UI with a "try another seat" flow.
  3. On stale (the user's view of availability was out of date), refreshes the seat map silently and re-tries the op against the new state.
  4. On error (5xx or network), retries with exponential backoff, up to 3 attempts, then falls back to "conflict" UX.

The four-state model came from watching real users. "Conflict" and "stale" feel the same to the code but are completely different to the user. A conflict means "try another seat." A stale means "wait, we updated the map for you, try again." Merging them would have made the UX feel unreliable.

The edge cases nobody warns you about

These all came from production.

1. Wi-Fi flapping

iOS in particular likes to tell you "online" when you've associated with a Wi-Fi network, before you've actually authenticated through the captive portal at a coffee shop. NetInfo says you're online. Your API calls time out. Your queue starts flushing into a void.

Fix: don't trust isConnected. Add an isInternetReachable check and a probe to your own health endpoint before starting to drain the queue. A request that fails with a network error should not decrement the retry counter — treat it as "still offline."

2. Android's background kills

Android will kill your app aggressively to reclaim memory. If you're mid-sync when it happens, you can leave ops in a "flushing" state that never resolves.

Fix: the queue stores only "pending" or "committed." There's no intermediate "flushing" state. Drainage is idempotent — if an op is already sent, the server returns the cached response via the idempotency key. Kill the app mid-flush, restart, replay, no duplicates.

3. Clock skew between device and server

User on a wonky train, phone clock is 3 minutes off. They reserve a seat, then release it, then reserve it again. The server gets them in an order based on server-side receive time. Their client thinks it sent them in a different order. The UI shows one thing; the server returns another.

Fix: every op carries a monotonic client-side sequence number (not a timestamp). The server uses it only for tie-breaking, not for ordering. Truth is the order of receipt at the server.

4. The "I'll just open the app later" problem

User reserves a seat at 10am. App goes to background. Phone dies. They charge it in the afternoon. Reopen the app at 4pm. The reservation expired at 10:15am. Their local state still thinks they have it.

Fix: any cached state older than the reservation TTL is soft-invalidated. When the app reopens, the first action is a GET /event/:id/state that reconciles. If seats the user thought they had are gone, they see a polite "your selection expired, here's what's available" screen instead of "processing payment."

5. Duplicate opening on two devices

Some users log in on iPad and phone. They pick seats on one, switch to the other, expect continuity.

Fix: the cart is server-side, keyed by user + event. Both devices read the same state. This was a feature more than an edge case — but it's worth mentioning because the offline-first model made this easier, not harder. The server is always authoritative for the cart; both devices converge.

The hard part that wasn't technical

Implementing this architecture was two weeks. Convincing the rest of the team that "tap feels instant" was more important than "tap is verified" was two meetings. The argument we kept coming back to:

On a spotty network, the user cannot tell the difference between "the network is working and the seat is reserved" and "the network is working and someone else got the seat first." Both feel like "I tapped and nothing happened." The only thing the user can tell the difference in is whether the UI is responsive.

So: respond instantly, reconcile quietly, surface conflicts with a clear recovery path. The user's model of what happened gets reconciled with the server's model only at checkout, where a slightly longer loading state is expected behavior.

The numbers after the rewrite

The last number was the most rewarding. The users who were confused before weren't tech-unsavvy — they were on trains. The architecture changed, and the product started meeting them where they were.

When to reach for this

You don't need offline-first for every app. The threshold I use:

For the E7 Shop specifically, the use case was ticket buyers on their way to events. That's almost definitionally a moving, patchy-network environment. Offline-first wasn't a nice-to-have; it was the product.

The lesson I keep re-learning

Every time I build something mobile-first, I under-estimate how much of the product's "feel fast" is really "feel responsive to my input, regardless of whether the network is cooperating." Spinner-on-every-tap is not performance. It's the admission that you're building for someone sitting at a desk on fiber — and then shipping to people on trains.


If you're building a React Native product that needs to survive patchy networks and want a second pair of eyes on the sync architecture — I do one-off reviews on mobile codebases (€500, 3-day turnaround, written report). Contact.