Offline support is usually requested as a feature and scoped as a cache. The app should work on the underground, someone says, and the estimate that comes back covers reading from a local store when the network is gone. That part is genuinely easy. The cost is on the write path, and it does not show up until the app has real users with real conflicting edits, by which point the data model is load-bearing and expensive to change.
It is worth being precise about what is being asked for, because three quite different things get called offline. Read-only caching means the user can see what they already loaded. Queued writes mean the user can act, and the action lands later. Genuine offline-first means the local database is the source of truth the interface reads from, and the network is a background detail. Only the third one survives a long flight, and only the third one forces you to answer the hard questions up front.
The first thing to break is the primary key. If the server assigns IDs, a record created offline has no identity until it syncs, so nothing else can reference it. A user who creates a client, then logs time against that client, then edits it, has produced three operations where two depend on the result of the first. Queueing raw HTTP calls does not survive this: the second request needs a value the first has not returned yet.
The fix is to move identity to the client. Generate the ID on the device and let the server accept it. UUIDv4 works and is what most teams reach for; UUIDv7 is worth preferring because it is time-ordered, which keeps B-tree index locality on the server side rather than scattering inserts across the keyspace. Once identity is client-side the queue holds a graph of operations that are internally consistent, and the server's job becomes accepting facts rather than minting them.
This has a consequence people miss: the server can no longer trust the ID. A client-supplied primary key is user input. It needs to be validated as a well-formed UUID, scoped to the authenticated tenant, and checked against collision — not because collisions are likely, but because a malicious client can pick any key it wants, including one belonging to somebody else.
A queued write will be sent twice. Not might — will. The network drops after the server commits but before the response is read, the app is killed mid-flush, the user force-quits during a sync, the retry policy fires on a timeout that was actually a slow success. If the same operation applied twice produces two rows, you will get duplicate orders, double-counted points and doubled charges, and you will find out from a customer.
Attach a stable key to each mutation at the moment it is created, not at the moment it is sent, and have the server store it against the result. A repeat arrives, the server recognises the key and returns the original outcome without applying anything. This is the same mechanism payment processors expose, for the same reason, and it is far cheaper to build in from the start than to retrofit onto a table that already has duplicates in it.
Device clocks are wrong. Users change time zones, set the clock manually, and run devices whose battery died and came back at the Unix epoch. If your conflict resolution is last-write-wins on a client timestamp, a single device with a clock a day fast will silently win every conflict it participates in, and nothing in your logs will look unusual.
Order by something the server controls. A monotonic server sequence per tenant, or a version number incremented on write, gives you a total order that no client can skew. Keep the client timestamp if you want to display it, but treat it as a label rather than as ordering data. If you genuinely need causality across devices — which most products do not — that is a vector clock or a CRDT, and you should decide that deliberately rather than discover it.
Most products can avoid the hardest version of this entirely by narrowing what is editable offline. Append-only data such as logged sessions, readings, photographs and events merges without conflict because nothing overwrites anything. A shared record that two people can edit simultaneously is where the difficulty lives, and it is often acceptable to make that specific case online-only rather than build a merge strategy for the whole app.
AsyncStorage is a key-value store with a single-digit-megabyte default on Android, and it serialises everything through JSON. It is fine for a token or a preference. It is the wrong place for a synced dataset, and the failure mode is not an error but a gradual slowdown as every read parses the whole blob.
For anything queryable the answer is SQLite, through expo-sqlite or op-sqlite, or a layer such as WatermelonDB if you want observable queries wired into the component tree. MMKV is excellent for small, hot values where you want synchronous reads. The choice matters less than the fact that you make it deliberately: a local store you can index and migrate is what makes offline-first tractable, and a bag of JSON is what makes it a rewrite eighteen months later.
Migrations deserve specific attention. Once data lives on the device, a schema change has to run against a database you cannot inspect, on a version of the app you no longer control, belonging to a user who may have skipped four releases. Migrations must be forward-only, individually reversible in effect if not in fact, and tested against a real database from a previous version rather than a freshly seeded one.
The obvious design is to flush the queue in the background so the user never waits. Both platforms will let you ask for this and neither will promise it. On iOS, BGTaskScheduler decides when your task runs based on usage patterns, battery and thermal state, and an app the user opens rarely may effectively never get a slot. Android's WorkManager is more dependable but still subject to Doze and manufacturer-specific battery management that is considerably more aggressive than stock.
Treat background execution as an optimisation and never as the path that makes data correct. The queue must flush on foreground, and the interface must be honest about state that has not yet synced. A small indicator showing pending changes is worth more than a background task you cannot rely on, because it converts an invisible failure into something the user can see and act on.
Turning off wifi is not a test. The interesting failures are the in-between states: a request that reaches the server and dies on the way back, a socket open long enough to be accepted and not long enough to complete, a sync interrupted halfway through a batch, two devices editing while both believe they are online. None of these appear by toggling airplane mode, and all of them appear in production.
Build a network layer you can drive from tests — one that can fail a specific request, delay a response past a timeout, or accept a write and drop the response — and write cases for replay, for out-of-order arrival, and for the app dying mid-flush. The reason this matters more than usual is that a sync bug does not throw. It silently produces wrong data, and by the time anyone notices, the wrong data has been synced everywhere.
Offline-first is not a library you add. It is a decision about identity, ordering and conflict that reaches into the schema on both sides of the network, and it is far cheaper to make deliberately at the start than to introduce once a server-authoritative model is in production. If the requirement is genuinely just that a user can read what they already loaded, say so and build the cache. If it is that the app is fully usable without a network, price the write path, because that is where the work is.
We design and build AI-powered platforms, web applications, and mobile products. Tell us what you are working on.
Get in touch