●MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode required●STACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decision●FOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generation●BUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebase●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●PRICING — It is free to start, with paid plans from $25/month, so you can try before committing●MAX — Rork Max generates native Swift for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro, with 2-click App Store publishing and no Xcode required●STACK — Standard Rork builds cross-platform mobile apps with React Native (Expo); choosing between the two by use case is the key decision●FOCUS — Unlike web-first tools such as Bolt or Lovable, Rork specializes in native iOS and Android app generation●BUGS — A hands-on review reports Rork resolved about 70% of bugs without manual help, with the remaining 30% needing edits in the exported codebase●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz)●PRICING — It is free to start, with paid plans from $25/month, so you can try before committing
Designing Apps That Keep Working When the Signal Drops — Optimistic Updates and Resolving Conflicts on Reconnect
Make the Expo apps you build with Rork keep responding even when the signal drops in a subway or elevator. We assemble optimistic updates that move the screen first, and conflict resolution that reconciles when the connection returns, in working code.
On the subway, I tried to check off a note in my own app, and the spinner would not stop. The signal had dropped. Because a one-tap action was built to wait for a network round trip, the app gave me nothing until we cleared the tunnel. From a user's point of view, that is indistinguishable from "broken."
What this drove home is that if you build the UI assuming connectivity, the app dies the moment connectivity is gone. Trains, elevators, basement shops — daily life is full of places where the signal cuts out. As an indie developer running several apps, low-rating reviews about behavior in these environments slowly chip away at your store stars.
Why a "wait first" design loses users
Many apps send an action to the server, wait for a success reply, and only then update the screen. The order feels natural, but it makes network slowness or loss translate directly into operation slowness. When an AdMob banner is loading in the background, the line is busier still and the wait stretches.
What the user wants is for the check to appear the instant they tap. The server's reply can honestly come later. So reverse the order: update the screen first, and let the send chase it afterward. That is the idea behind optimistic updates.
Update the screen first, push the send to the back
The skeleton of an optimistic update is simple. On tap, change local state immediately and, at the same time, enqueue "the operation that should go to the server." If the send succeeds, drop it from the queue; if it fails, keep it and retry later.
// optimisticStore.ts — change the screen first, enqueue the sendtype Mutation = { id: string; type: "toggle"; itemId: string; value: boolean };const pending: Mutation[] = [];let items: Record<string, boolean> = {};export function toggleItem(itemId: string, value: boolean, render: () => void) { // 1) update the screen first (perceived as instant) items[itemId] = value; render(); // 2) enqueue the operation to send const mutation: Mutation = { id: `${itemId}-${Date.now()}`, type: "toggle", itemId, value, }; pending.push(mutation); void flushQueue();}
From the user's finger, the check appears in zero seconds. The network proceeds quietly underneath. The improvement in perceived speed is dramatic — in my app, the wait from action to feedback shrank from a measured ~400 milliseconds to nearly zero.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Why operations freeze the instant the signal drops, and a design that makes perceived speed effectively zero with optimistic updates
✦Working conflict-resolution code that safely reconciles server and local divergence when the connection returns
✦How to persist the send queue so operations survive even if the app is killed
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Here is the first pitfall. If the queue lives only in memory, then when the OS discards the process as the user switches apps, every pending operation vanishes. The user believes they "checked it," but it never reached the server. This is silent data loss — the kind of bug that damages trust most.
So always persist the queue to disk. On Expo, AsyncStorage is enough.
// queuePersist.ts — save the pending queue to disk, restore on launchimport AsyncStorage from "@react-native-async-storage/async-storage";const KEY = "pending_mutations_v1";export async function savePending(pending: Mutation[]) { await AsyncStorage.setItem(KEY, JSON.stringify(pending));}export async function loadPending(): Promise<Mutation[]> { const raw = await AsyncStorage.getItem(KEY); if (!raw) return []; try { return JSON.parse(raw) as Mutation[]; } catch { // discard corrupt data; do not drag the whole queue down with it await AsyncStorage.removeItem(KEY); return []; }}
Call savePending each time you enqueue, and on launch restore with loadPending before attempting resends. Now even if the user closes the app inside the tunnel, the send resumes the next time they open it.
How to resolve the divergence on return
The second — and genuinely hard — pitfall is conflict. While offline, you marked an item "done." But at the same time, another device or a sync job reverted the same item to "not done" on the server. The instant the connection returns, local and server disagree.
If you naively "let the latest write win," the user's action silently disappears, or the correct server state gets trampled. What I run in production is to attach a timestamp and a device identifier to each change, adopt the newer change, and record the one you discarded.
// resolveConflict.ts — timestamp wins, but record the discarded side for visibilitytype Change = { value: boolean; updatedAt: number; device: string };export function resolve(local: Change, server: Change): { winner: Change; discarded: Change | null;} { if (local.updatedAt === server.updatedAt) { // exact ties are rare; decide mechanically by device name to avoid loops return local.device < server.device ? { winner: local, discarded: server } : { winner: server, discarded: local }; } return local.updatedAt > server.updatedAt ? { winner: local, discarded: server } : { winner: server, discarded: local };}
The crux is not to swallow discarded. Log the change you dropped so that if an important operation (like a billing-state change) is lost to a conflict, you can detect and remedy it later. I recommend that for money- and account-related items only, you do not leave it to automatic overwrite — when a conflict occurs, prompt the user to confirm. Letting a machine quietly decide a money-related divergence is, in my view, dangerous in production.
Detect reconnection and resend automatically
Finally, what ties these together is connectivity monitoring. Detect recovery with @react-native-community/netinfo and flush the queue the moment you are back online.
Restore the persisted queue on app launch
When connectivity recovers, send the queue in order
Receive server state in each send's response and run conflict resolution
Finalize local state with the resolution result and drop that operation from the queue
Wire these four steps into a single flow and the user stops even noticing that the signal had dropped. The check they tapped inside the tunnel syncs a few seconds after they surface, as if nothing happened.
What changed after shipping it: the reviews
Before and after this design, the words in my App Store reviews changed. Previously there were a steady number of complaints like "freezes on the train" and "loading never finishes." In the months after adding optimistic updates and a persistent queue, those reviews nearly disappeared, replaced by short notes like "snappy." In indie development each review affects ranking, so this shift quietly feeds back into revenue too.
One practical implementation note. Because optimistic updates assume "this may fail later," it is safest to decide up front how to roll the screen back on failure. In my case, I settled on a restrained toast — "Could not save. Retry?" — shown only for operations that failed all three resend attempts. Rolling back silently breeds confusion, so if you roll back, always add a word. That is a judgment I earned in production.
Your next move
Pick just one of your most-used operations and convert it to an optimistic update first. You do not need to change every screen at once. Starting from reversible actions like a check or a "like" lets you safely confirm the perceived-speed gain before stepping into the hard part of conflict resolution. Add queue persistence on top, and the worst experience — an app dying offline — all but disappears.
The more an app is used in weak-signal places, the more quietly this design pays off. I hope it helps anyone facing the same challenge.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.