Offline-First React Native: Lessons from an App with 100K Users
How to build offline-first React Native apps that never lose data: local-first storage, an outbox for writes, sync, conflicts and testing bad networks.
Full-Stack, Mobile & AI Engineer
Someone finishes a 45-minute workout in a basement gym with no signal. They tap "Save", close the app, and expect every set, rep and weight to be there tomorrow, on every device. If the app showed a spinner and quietly dropped the session, they won't open it again.
Offline-first React Native is the design that makes that tap safe: the app writes to the device first and syncs with the server when it can. I built offline-first workout tracking with cache-first sync for MOVE, a subscription fitness app with 100K+ active users, so sessions persist through network drops with zero data loss. This guide covers the architecture, the sync and conflict decisions, and how to test it properly.
Key takeaways
- Offline-first means the local database is the source of truth for the UI; the network is how data syncs, not how screens get data.
- Write locally, then sync through an outbox: a durable queue of changes, each with an ID generated on the device.
- Make server endpoints idempotent, so a change sent twice is applied once.
- Decide conflict rules up front per type of data: last write wins, merge, or keep both.
- Test with airplane mode, flaky networks and the app killed mid-sync, not only on office Wi-Fi.
Does React Native work offline?
Yes. A React Native app, Expo or bare, ships its JavaScript bundle inside the app, so it launches and navigates without a connection. What doesn't work offline by default is your data: any screen that fetches from an API on open shows a spinner or an error. "Offline mode" is the design work of deciding which data lives on the device, how changes are queued, and how they sync back.
Cache-first vs offline-first
These are related but different:
| Cache-first | Offline-first | |
|---|---|---|
| Reads | Show cached data instantly, refresh in background | Read from the local database |
| Writes | Need the network (or fail) | Saved locally, synced later |
| Good for | Feeds, catalogs, dashboards | Workouts, field reports, notes, orders taken in the field |
| Typical tools | TanStack Query with a persisted cache | SQLite, WatermelonDB, a sync engine |
Many apps need both: cache-first for content they read, offline-first for the data users create.
The architecture
- Local database. SQLite (
expo-sqliteorop-sqlite), or WatermelonDB on top of SQLite for large datasets. MMKV is fine for small settings, not for collections that grow. - The UI reads from the local database and updates reactively when it changes.
- Writes go to the local database and to an outbox in the same transaction.
- A sync process sends outbox entries to the server and pulls changes since the last sync.
- The server applies changes idempotently and returns anything the device hasn't seen yet.
The outbox pattern
Every change the user makes becomes an outbox entry with an ID created on the device:
import * as Crypto from "expo-crypto";
type OutboxEntry = {
id: string; // generated on the device, used as the idempotency key
entity: "session";
op: "upsert" | "delete";
payload: unknown;
createdAt: number;
attempts: number;
};
export async function saveSession(db: DB, session: Session) {
const entry: OutboxEntry = {
id: Crypto.randomUUID(),
entity: "session",
op: "upsert",
payload: session,
createdAt: Date.now(),
attempts: 0,
};
await db.transaction(async (tx) => {
await tx.sessions.upsert(session); // the UI sees it immediately
await tx.outbox.insert(entry); // durable record of what must sync
});
}The sync loop sends entries in order, removes each one after the server confirms it, and retries failures with backoff. Because the entry ID travels with the request, a retry after a timeout can't create a second session.
Give records themselves stable IDs generated on the device too (UUIDs), so a session has the same identity locally and on the server from the moment it's created.
The server side
- Accept client-generated IDs and upsert by them.
- Store processed change IDs, so a repeated request is acknowledged without being applied twice.
- Track
updated_at(or a version number) per record, and expose "changes since cursor X" for the pull side. - Use soft deletes (tombstones) so deletions sync to devices that were offline when they happened.
Conflicts
Conflicts happen when the same record changes in two places before syncing. Pick a rule per kind of data:
| Data | Sensible rule |
|---|---|
| Completed workout sessions, logs, events | Append-only: never conflict, just add |
| Profile fields, settings | Last write wins, per field |
| Shared lists, counters | Merge operations rather than overwrite values |
| High-stakes records (payments, bookings) | Server decides; device shows the server's answer |
Design data so most of it is append-only. A completed workout is a fact, not a document to edit, and facts don't conflict.
Detecting connectivity (and not trusting it)
Use NetInfo to react to connection changes and to trigger a sync when the device comes back online, but don't treat "connected" as "the server is reachable". Captive portals, weak signals and server outages all look "online". The real signal is whether a sync request succeeds. Sync on app start, on reconnect, on returning to the foreground and on a background task schedule (expo-background-task on Android and iOS, within each OS's limits).
From the field: On MOVE, a subscription fitness platform with 100K+ active users, I implemented offline-first workout tracking with cache-first sync, so sessions persist through network drops with zero data loss, alongside streaks, progress tracking and a 500+ day video library with adaptive playback. The principle that makes it work: the user's tap saves to the device, and everything after that is the app's job.
Showing sync state honestly
Users trust offline apps when they can see what's happening:
- A small "Saved on this device, will sync" state for pending items.
- A clear error with a retry only when sync fails repeatedly.
- No blocking spinners for things that are already saved locally.
Where TanStack Query fits
TanStack Query is a server-state cache, and with a persister it becomes a good cache-first layer: queries are saved to storage and shown instantly on the next launch. Connected to NetInfo through its onlineManager, it also pauses mutations while offline and resumes them when the connection returns. That covers many apps. When users create and edit significant data offline, or several devices edit the same records, a local database with an outbox (as above) gives you control that a query cache doesn't.
Offline-first libraries for React Native
| Library | What it gives you | Good for |
|---|---|---|
expo-sqlite plus your own sync | A local SQL database; you build the outbox and sync | Full control, custom backends |
| WatermelonDB | A reactive local database with a sync protocol you implement on the server | Large local datasets, many records |
| PowerSync | A sync engine between a backend database (such as Postgres) and SQLite on the device | Teams that want sync handled for them |
| Legend-State | Local-first state with persistence and sync plugins | Apps with modest data and simple sync |
| TanStack Query with persistence | Cached server data and paused mutations | Cache-first apps with light offline writes |
Avoid starting new projects on Realm's Atlas Device Sync, which reached end of life on 30 September 2025.
Offline maps, media, AI and notifications
- Maps: Mapbox and MapLibre can download map regions for offline use. Google's Maps SDKs don't offer downloadable offline areas inside your own app, so choose the map provider with offline needs in mind.
- Video and files: download what users need to app storage with
expo-file-systemand play or open it from the local file; show storage use and let users delete downloads. - AI features: on-device models (Apple's Foundation Models on supported iPhones, Gemini Nano on supported Android phones) work without a connection; cloud models don't.
- Notifications: local notifications scheduled on the device fire offline; push notifications need the network.
Testing offline behaviour
- Airplane mode during every core flow, then reconnect.
- Flaky networks with Network Link Conditioner (iOS) or the Android emulator's network settings.
- Kill the app mid-sync and relaunch; nothing should be lost or duplicated.
- Two devices, same account, editing the same record offline, then syncing.
- Long offline periods, so the outbox has hundreds of entries.
FAQ
What does offline-first mean in React Native?
It means the app reads from and writes to a local database on the device, so it works without a connection, and syncs changes with the server in the background when it can. The network becomes a sync mechanism rather than a requirement for every screen.
Which database is best for offline-first React Native apps?
SQLite is the usual foundation, through expo-sqlite or op-sqlite, often with an ORM like Drizzle. WatermelonDB adds lazy loading and a sync protocol for large datasets. MMKV is excellent for small key-value data but isn't a database for growing collections.
How do I prevent duplicate data when syncing?
Generate IDs on the device for both records and changes, send the change ID with every sync request, and make the server store processed IDs so retries are acknowledged without being applied again. Upserting by client-generated record IDs prevents duplicate records.
How do offline apps handle conflicts?
By choosing a rule for each type of data: append-only for events like completed workouts, last-write-wins per field for profiles and settings, merging operations for shared lists, and server authority for payments and bookings. Designing data as append-only where possible avoids most conflicts.
Next steps
If your app loses data on bad networks, or you're planning a product that must work offline, I can design and build the storage, outbox and sync, and test it under real conditions. Get in touch, or see how I work as a React Native developer.
Sources
- Offline support in TanStack Query, TanStack
- PowerSync documentation, PowerSync
- SQLite (expo-sqlite), Expo
- NetInfo, GitHub
- BackgroundTask (expo-background-task), Expo
- WatermelonDB sync, WatermelonDB
- Crypto (expo-crypto), Expo
Related
WatermelonDB vs SQLite vs Realm in 2026
WatermelonDB vs SQLite vs Realm for React Native in 2026: performance, sync, the end of Atlas Device Sync, and which local database to choose.
MMKV vs AsyncStorage vs SQLite: Picking Local Storage
MMKV vs AsyncStorage vs SQLite for React Native in 2026: speed, data shape, encryption and size limits, which to use for what, and how to migrate.
TanStack Query in React Native: Caching and Offline Persistence
Use TanStack Query in React Native properly: app focus and online state, persisted caches for instant startup, offline mutations and infinite feeds.
