Offline-first React Native: data sync patterns that hold up
· 4 min read · Amrith Vengalath
- React Native
- Offline
- Realm
- WatermelonDB
"Offline-first" gets thrown around like a checkbox, but building an app that actually works on a flaky subway connection - reads instantly, accepts writes, and reconciles when the network comes back - is one of the harder things you can take on in mobile. I've built versions of this a couple of times, and the architecture matters a lot. The naive approach (cache responses, hope for the best) falls apart the moment users write data offline.
Here's the shape that holds up, and an honest map of where the dragons are.
The core idea: the local DB is the source of truth
The mental flip that makes offline-first work: your UI reads and writes a local database, not the network. The network is a background process that syncs the local DB with the server. The user never waits on a request to see their own data, because they're always reading local state.
This is the opposite of the typical "fetch from API, show spinner, render" flow, and it's the whole game. Get this right and offline mostly falls out; get it wrong and you're bolting offline onto a network-first app forever.
Picking the local database
Two that I've reached for in React Native:
- Realm - an object database with a reactive query model; components can subscribe to query results and re-render when the data changes. Good when you want a rich local model and live queries. It also has a sync product if you want managed server sync, though you can absolutely use it purely locally and sync yourself.
- WatermelonDB - built specifically for React Native, lazy and fast on large datasets, with a sync protocol designed around the pull-changes/push-changes model. If your offline dataset is large, its laziness is a real advantage.
Plain AsyncStorage is fine for small key-value settings but is the wrong tool for relational, queryable offline data. Don't try to build offline-first on top of it.
Reads are the easy part
With a local DB as the source of truth, reads are simple: query local, render, done. No spinner for data the user already has. A background sync updates the local DB, and because the queries are reactive (both Realm and Watermelon support this), the UI updates when new data arrives. The user sees their data instantly and watches it freshen when the network's available.
Writes are where it gets real: the outbox
Writes are the hard part, because the user can create and edit data with no connection, and those changes have to reach the server eventually, in order, exactly once. The pattern that works is an outbox (a.k.a. an operation queue):
- The user makes a change. You write it to the local DB immediately so the UI updates, and you append an operation to an outbox table.
- A sync process drains the outbox when there's connectivity, sending each operation to the server.
- On success, you mark the operation done. On failure, it stays queued and retries.
// conceptually: every mutation is local write + queued operation
async function updateNote(id, text) {
await localDb.notes.update(id, { text, dirty: true });
await outbox.enqueue({ type: "UPDATE_NOTE", id, text });
}
// a background worker drains the outbox when online, with retry/backoffThe outbox is what makes writes durable across app restarts and network gaps. Without it, an offline write lives only in memory and dies when the app is killed.
The genuinely hard part: conflicts
Here's where I won't pretend it's easy. Two devices (or the same user on two devices) edit the same record while offline. They both sync. Now what?
The strategies, roughly in increasing order of effort:
- Last-write-wins. Simplest, sometimes fine, but it silently loses data. Acceptable for low-stakes fields, dangerous for anything important.
- Server-authoritative with versioning. Each record has a version; the server rejects a write based on a stale version and the client reconciles. More work, far safer.
- Field-level merge or CRDTs. For collaborative editing where you genuinely can't lose either side's changes. Powerful and genuinely complex - only go here if the product truly needs it.
The mistake is picking last-write-wins by default because it's easy and discovering later that users are losing data. Decide consciously, per type of data, how much a conflict can cost.
What I'd tell someone starting
Make the local database the source of truth from day one - retrofitting that onto a network-first app is brutal. Use a real local DB, not AsyncStorage. Build the outbox for writes early; it's not optional if users edit offline. And treat conflict resolution as a real design decision with real tradeoffs, not an afterthought - it's the part that separates an app that feels offline-capable from one that actually keeps users' data safe when two of them edit the same thing on a bad connection.
Offline-first is a lot of work. But for an app people use on the move, it's the difference between "unusable in a tunnel" and "didn't even notice the network dropped," and users absolutely notice which one you built.