Offline-First Sync for React Native
Outbox, cursors, idempotency and conflict resolution for apps that must work without a connection.
Field and enterprise apps cannot assume a good connection. An offline-first app treats the network as an optimisation: the local database is the source of truth the user sees, and a sync engine reconciles it with the server in the background. This guide covers the architecture, the data you need to track, conflict handling, and how to test it, using React Native and TypeScript examples.
Why offline-first
- Users work in basements, warehouses, remote areas and on poor mobile data.
- The app feels instant because every read and write hits local storage.
- Lost connectivity stops being an error and becomes a normal state.
The price is complexity: you must decide how changes travel, what happens when two people edit the same record, and how to prove nothing was lost.
Architecture
The UI never talks to the network. It reads and writes through a repository, which updates the local database and records the change in an outbox. A separate sync engine pushes the outbox and pulls remote changes.
Choosing local storage
| Option | Best for | Notes |
|---|---|---|
| MMKV | Small key-value data, flags, tokens, caches | Very fast, synchronous, not relational |
| AsyncStorage | Tiny, simple persistence | Slower, async, avoid for large data |
SQLite (for example via expo-sqlite) | Relational data and queries | Full control, you write the sync layer |
| WatermelonDB | Large relational datasets with reactive queries | Has built-in sync primitives |
Use MMKV for settings and the sync cursor, and a relational store for business records.
What every synced record needs
interface SyncedRecord {
id: string; // client-generated UUID, so it can be created offline
updatedAt: number; // last modified, set by the server on accept
version: number; // incremented on every accepted change
deletedAt: number | null; // tombstone instead of hard delete
}
Client-generated ids mean you can create records offline and link children to them before the server has ever heard of them. Tombstones let a delete travel to other devices.
The outbox
Every local write also appends a mutation to the outbox. A mutation is a small description of intent, not a copy of the whole row.
interface Mutation {
id: string; // idempotency key, unique per mutation
entity: "workOrder";
entityId: string;
type: "create" | "update" | "delete";
patch: Record<string, unknown>;
baseVersion: number; // the version this change was made against
createdAt: number;
attempts: number;
}
async function saveWorkOrder(id: string, patch: Partial<WorkOrder>) {
await db.transaction(async () => {
await workOrders.update(id, patch); // local write, UI updates now
await outbox.add({
id: uuid(),
entity: "workOrder",
entityId: id,
type: "update",
patch,
baseVersion: (await workOrders.get(id)).version,
createdAt: Date.now(),
attempts: 0,
});
});
syncSoon();
}
The local write and the outbox entry must commit in one transaction. Otherwise a crash can leave a change that is saved but never sent.
Sync: push then pull
export async function syncOnce() {
const pending = await outbox.nextBatch(50);
if (pending.length) {
const res = await api.push(pending); // server applies each mutation at most once
await outbox.markDone(res.accepted);
await handleConflicts(res.conflicts);
await outbox.recordFailure(res.rejected);
}
let cursor = await storage.getCursor();
while (true) {
const page = await api.pull(cursor);
await db.applyRemoteChanges(page.changes); // inside one transaction
cursor = page.cursor;
await storage.setCursor(cursor);
if (!page.hasMore) break;
}
}
Rules that keep this correct:
- Idempotency: the server remembers mutation ids it already applied and ignores repeats, so retries are safe.
- Cursors, not timestamps: the pull cursor is an opaque server value. Device clocks drift, so never use the phone's clock to decide what is newer.
- Batches: send and fetch in pages so a bad connection does not lose everything.
- Apply atomically: apply a page of remote changes in one transaction and save the cursor in the same step.
Handling conflicts
A conflict occurs when two devices change the same record from the same starting version. Choose a strategy per entity, not once for the whole app.
| Strategy | How it works | Good for | Risk |
|---|---|---|---|
| Last write wins | Newest accepted change overwrites | Low-value fields like a "last seen" flag | Silently loses edits |
| Field-level merge | Merge non-overlapping field changes, conflict only on the same field | Most forms and records | More logic |
| Server rules | Server decides by business logic, for example status can only move forward | Workflows and state machines | Needs clear rules |
| Manual resolution | Show both versions and let the user pick | High-value data, legal or financial fields | Interrupts the user |
function mergeFields(base: Rec, local: Rec, remote: Rec) {
const merged = { ...remote };
const conflicts: string[] = [];
for (const key of Object.keys(local)) {
const localChanged = local[key] !== base[key];
const remoteChanged = remote[key] !== base[key];
if (localChanged && !remoteChanged) merged[key] = local[key];
else if (localChanged && remoteChanged && local[key] !== remote[key]) conflicts.push(key);
}
return { merged, conflicts };
}
Whatever you choose, never drop a change without telling someone. Log every automatic resolution.
Files and photos
Binary uploads follow the same idea, with a queue of pending uploads:
- Save the file locally first and reference it by a local path.
- Upload in the background, resumable if possible, then record the remote URL.
- Compress before upload and enforce size limits.
- Keep the record usable while the upload is pending.
Background sync
Syncing only while the app is open leaves data stranded on the device. Add:
- A connectivity listener that triggers sync when the network returns.
- Periodic background work using the platform's task APIs (in Expo,
expo-task-managerand the current background task module; check the docs for your SDK version, as these APIs have changed). - Exponential backoff with a cap, so a failing server is not hammered.
- Sensible limits on battery, data use and frequency.
Background execution is best effort. The OS decides when it runs, so the app must also sync when it opens.
Security
- Store tokens in secure storage, not in plain key-value storage.
- Encrypt sensitive local data and wipe it on logout or device removal.
- Authorise every pushed mutation on the server. Never trust the client.
- Handle revoked access: a user removed from a job must lose that data on the next sync.
Show the user what is happening
- A small sync indicator: synced, syncing, offline, needs attention.
- A count of pending changes.
- Clear errors with a retry action, and a visible list of conflicts to resolve.
Test the hard cases
- Airplane mode for a whole workflow, then reconnect.
- Kill the app mid-sync and restart.
- Two devices edit the same record offline.
- Duplicate pushes and replayed responses.
- Large backlogs after days offline.
- Clock changes on the device.
- Property-based tests that apply random sequences of offline edits and assert that all devices converge.
Metrics to watch
Sync lag, pending outbox size, failed push rate, conflict rate, payload size and time to first sync after install. These tell you about problems before users do.
Common mistakes
- Writing to the UI state and the database separately, so they drift.
- Using device timestamps to order changes.
- Hard deletes that never reach other devices.
- No idempotency, so a retry duplicates data.
- Treating "last write wins" as a safe default for everything.
- Testing only on a fast connection.
Checklist
- Local database is the source of truth for the UI
- Client-generated ids and tombstones
- Local write and outbox entry in one transaction
- Idempotent server push, cursor-based pull
- Conflict strategy chosen per entity and logged
- Background sync with backoff, plus sync on app open
- Sync status visible to the user
- Offline, crash and two-device tests automated