Offline & Data· 7 min read

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.

By

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-firstOffline-first
ReadsShow cached data instantly, refresh in backgroundRead from the local database
WritesNeed the network (or fail)Saved locally, synced later
Good forFeeds, catalogs, dashboardsWorkouts, field reports, notes, orders taken in the field
Typical toolsTanStack Query with a persisted cacheSQLite, WatermelonDB, a sync engine

Many apps need both: cache-first for content they read, offline-first for the data users create.

The architecture

  1. Local database. SQLite (expo-sqlite or op-sqlite), or WatermelonDB on top of SQLite for large datasets. MMKV is fine for small settings, not for collections that grow.
  2. The UI reads from the local database and updates reactively when it changes.
  3. Writes go to the local database and to an outbox in the same transaction.
  4. A sync process sends outbox entries to the server and pulls changes since the last sync.
  5. 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:

DataSensible rule
Completed workout sessions, logs, eventsAppend-only: never conflict, just add
Profile fields, settingsLast write wins, per field
Shared lists, countersMerge 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

LibraryWhat it gives youGood for
expo-sqlite plus your own syncA local SQL database; you build the outbox and syncFull control, custom backends
WatermelonDBA reactive local database with a sync protocol you implement on the serverLarge local datasets, many records
PowerSyncA sync engine between a backend database (such as Postgres) and SQLite on the deviceTeams that want sync handled for them
Legend-StateLocal-first state with persistence and sync pluginsApps with modest data and simple sync
TanStack Query with persistenceCached server data and paused mutationsCache-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-system and 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

Bhuwanesh Sisodia

Bhuwanesh Sisodia

Full-Stack, Mobile & AI Engineer

Bhuwanesh Sisodia is a Full-Stack, Mobile & AI Engineer based in Mumbai, with 7+ years of experience and 50+ apps shipped to the App Store and Google Play. He works independently with startups and product teams worldwide.

About meGitHubLinkedInX

Related