Offline-First React Native in 2026: WatermelonDB, MMKV, and the Sync Bugs That Only Show Up on Airplanes
Offline-first sounds simple until a user boards a flight with three unsaved edits and a token that expired mid-flight. Here's how we actually build it in React Native without regretting the architecture six months later.

Every offline-first app looks fine on the simulator. The bugs show up when someone edits three records on a plane, lands in a different timezone, and opens the app while their auth token is refreshing on a flaky 3G connection. That's the scenario we design for now, and it's also the scenario most tutorials skip.
This is a field guide to building offline-first React Native apps in 2026 — what we reach for, what we've stopped reaching for, and the sync bugs that only surface once real humans are using the thing.
Why offline-first is a product decision, not a technical one
Before anything else: offline-first is expensive. You are effectively building a distributed system where one of the nodes (the phone) is unreliable, low-power, and controlled by a user who will force-quit your app mid-write. If your product doesn't actually need it, don't do it.
We apply a rough test with clients:
- Do users work in environments with unreliable connectivity (field work, transit, warehouses, aviation, healthcare)?
- Is losing an in-progress edit a real problem, or just a mild annoyance?
- Does the data model have enough write conflicts to justify a merge strategy?
If the answer is "no, no, no," a good loading state and optimistic UI on top of REST will serve you better than any of what follows.
If the answer is yes to at least two — keep reading.
The 2026 stack we actually ship
After a few years of trying almost every combination, here's what we default to for production React Native apps that must work offline:
- WatermelonDB for relational, syncable data at scale (thousands of rows or more)
- MMKV for key-value state, session, and small caches
- TanStack Query for server state that doesn't need local persistence
- Expo with a custom dev client (WatermelonDB needs a native module, so Expo Go is out)
We've stopped defaulting to Realm for new projects — not because it's bad, but because MongoDB's licensing changes and the shifting Atlas Device Sync story make us nervous about long-term ownership. SQLite via expo-sqlite is fine for smaller apps, but you'll end up writing your own sync layer, which is where most projects quietly implode.
Why WatermelonDB earned the default slot
WatermelonDB is built around lazy loading and observables, which means it stays fast even with tens of thousands of rows. More importantly, it ships with an opinionated sync protocol: pull changes, push changes, resolve. You still write the server side, but the client contract is defined for you.
The part that matters in practice: it separates "synced" from "unsynced" state at the record level. Every record carries a _status field (created, updated, deleted, synced). This tiny detail is what makes conflict resolution tractable.
Modelling data for sync, not for the UI
The biggest architectural mistake we see: modelling local data to match a screen instead of modelling it to sync.
A safe schema looks something like this:
import { Model } from '@nozbe/watermelondb';
import { field, date, readonly } from '@nozbe/watermelondb/decorators';
export default class Task extends Model {
static table = 'tasks';
@field('server_id') serverId!: string | null;
@field('title') title!: string;
@field('status') status!: 'todo' | 'done';
@field('updated_by') updatedBy!: string;
@date('client_updated_at') clientUpdatedAt!: Date;
@readonly @date('created_at') createdAt!: Date;
@readonly @date('updated_at') updatedAt!: Date;
}
A few things worth pointing out:
serverIdis separate from the local id. WatermelonDB generates local ids immediately. The server assigns its own id on first sync. Never conflate them.clientUpdatedAtis tracked separately fromupdatedAt. The former is set by the device; the latter is managed by WatermelonDB. You need both to detect conflicts.updatedByis stored on the row. When a user has multiple devices, this is how you avoid the "my iPad overwrote my iPhone" bug.
Sync: the four failure modes that will bite you
The happy path is boring. Here are the four failure modes we now test for explicitly before shipping.
1. Token expiry mid-sync
User opens the app on a plane. Token is expired. They make edits. They land, the app tries to sync, the refresh token endpoint fails because the refresh token also expired. Now what?
Rule: never discard unsynced local writes on auth failure. Queue the sync. Show a passive banner. Let the user re-authenticate at their pace. We've seen apps helpfully "log out" the user on 401 and wipe local state — a data loss bug dressed as a security feature.
2. Clock skew
Device clocks lie. Users set them manually. Timezones change mid-flight. If your conflict resolution is latest timestamp wins, you will lose writes.
Our rule: the server timestamp is truth. Client timestamps are used only for ordering local operations before they reach the server. When conflicts occur, the server decides.
3. Partial sync failure
A batch of 200 changes is pushed. The server accepts 197 and rejects 3 due to validation. What happens to the other 197?
This is where hand-rolled sync usually falls apart. WatermelonDB's protocol handles this if — and only if — your server returns per-record status, not a single 200/400. Design your API for this on day one:
{
"accepted": ["uuid-1", "uuid-2"],
"rejected": [
{ "id": "uuid-3", "reason": "validation", "message": "title too long" }
],
"server_time": "2026-03-14T09:12:44Z"
}
The rejected records need to be marked locally so the user sees them and can fix them. Silently dropping them is how you lose trust.
4. Deleted-on-server, edited-on-client
User edits a record offline. Meanwhile, a teammate deletes the same record from the web. On sync, the client has updates for a record the server no longer has.
There is no correct answer here — only a product decision. Options:
- Resurrect the record with the client's edits
- Discard the local edits and honour the delete
- Move the local edits to a "conflicts" view for user resolution
We usually go with option three. It's the most work but it's the only one that doesn't lie to the user.
Where MMKV fits
MMKV is not a replacement for WatermelonDB. It's a replacement for AsyncStorage, and it's roughly an order of magnitude faster in our benchmarks (rough range — synchronous reads in microseconds versus milliseconds).
We use it for:
- Auth tokens and session state
- Feature flags fetched at launch
- The last-known sync timestamp
- UI preferences (theme, language, sort order)
- Small caches that don't need querying
import { MMKV } from 'react-native-mmkv';
export const storage = new MMKV({ id: 'app-state' });
export const secureStorage = new MMKV({
id: 'secure',
encryptionKey: getKeychainKey(),
});
One caution: MMKV encryption is not a substitute for the iOS Keychain or Android Keystore for actual secrets. Use it for defence in depth, not as the primary store for tokens on a rooted device.
Native comparison: is this easier in Swift or Kotlin?
Honestly? Sometimes.
On iOS, Core Data with CloudKit sync is genuinely excellent if your app is Apple-only and your data model fits CloudKit's constraints. You get sync, conflict resolution, and multi-device support largely for free.
On Android, Room plus WorkManager plus a hand-rolled sync layer is more work than WatermelonDB, but you get finer control over background sync scheduling — which matters if you're syncing large payloads on metered connections.
Where React Native wins is when you need feature parity across both platforms and you don't want to maintain two sync implementations. Where it loses is when your offline requirements are asymmetric — e.g., iOS needs background sync every 15 minutes but Android needs it every hour with battery-aware scheduling. In that case, you're writing native modules anyway, and the cross-platform argument weakens.
Testing the plane scenario
Before shipping, we now run a scripted test we call the flight test:
- Sign in on device A, sync fully.
- Enable airplane mode.
- Create 5 records, edit 5, delete 2.
- Force-quit the app.
- On device B (still online), edit 3 of the records device A also edited.
- Reopen device A, disable airplane mode after 30 seconds of usage.
- Verify: no data loss, conflicts surfaced to the user, no silent overwrites.
If that test passes, you're probably fine. If it doesn't, no amount of unit testing will save you.
Where we'd start
If you're greenfielding an offline-first React Native app in 2026: set up a dev client with WatermelonDB and MMKV on day one, write the sync endpoint contract (with per-record status) before the first screen, and build the flight test into CI as an end-to-end scenario. Everything else — schemas, screens, offline UI states — follows from those three decisions.
And if you're retrofitting offline-first onto an app that wasn't designed for it, budget more time than you think. The migration is almost always harder than the greenfield build. We've written about the mobile work we do if you'd rather not learn these lessons the expensive way.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Background Tasks in React Native: What Actually Runs on iOS and Android in 2026
Background execution is where React Native apps quietly break. Here's what iOS and Android actually let you run in 2026, and how we ship features that survive Doze, low-power mode, and app review.

Biometric Auth in React Native: Face ID, Android Keystore, and the Silent Lockouts Nobody Warns You About
Face ID and fingerprint auth look like a two-line integration until users start getting locked out after an OS update. Here's how we ship biometric login in React Native without the 3am support tickets.

Push Notifications in React Native 2026: Expo Push, FCM HTTP v1, and APNs Tokens That Silently Expire
Push looks simple until 30% of your Android sends fail overnight and iOS silent notifications stop waking your app. Here's how the stack actually breaks in 2026 and how we wire it.
