Field apps get used in basements, in lifts, on sites with one bar of EDGE, and on phones whose owners turned mobile data off to protect their allowance. If the app's answer to that is a spinner, it is not finished. Offline-first is not a feature you add in month four. It is a decision about where the truth lives, and retrofitting it means rewriting every screen that touches data.
The device database is the source of truth
One rule makes everything else fall into place: the UI reads from and writes to the device only. The network is a background process reconciling the device with the server. No screen awaits a request in order to render, and no button waits on a round trip before showing its effect.
That inverts the usual React Native data layer. Instead of a query hook that fetches and caches, you have a local store the UI subscribes to and a sync worker that feeds it. We use SQLite through expo-sqlite or op-sqlite with a thin repository layer, or WatermelonDB when the model is large enough that its observable collections pay for themselves. Realm and PowerSync are reasonable if you would rather buy a sync engine than build one, as long as you accept you are buying its conflict model too.
Every record carries three columns beyond its domain fields: updated_at as last seen from the server, local_updated_at from the device, and a dirty flag set when the device has changed a row the server has not acknowledged.
The outbox is the whole sync design
A write goes to two places in one transaction: the domain table, and an outbox of intents. Not row snapshots, intents. "Set job status to complete" survives reordering and redelivery. "PUT this entire job object" overwrites whatever else happened in the meantime.
-- device-local schema
create table outbox (
id text primary key, -- uuid, doubles as the idempotency key
entity text not null, -- 'job' | 'note' | 'photo'
entity_id text not null,
op text not null, -- 'create' | 'update' | 'delete'
payload text not null, -- json of changed fields only
base_version integer, -- server version the edit was made against
created_at integer not null,
attempts integer not null default 0,
next_attempt_at integer not null default 0,
last_error text
);
create index outbox_ready on outbox (next_attempt_at, created_at);
Three properties earn their keep. The row id is generated on the device and used as the idempotency key, so redelivery after a dropped response costs nothing. payload holds only changed fields, which makes per-field merging possible later. base_version records what the edit was made against, letting the server detect a genuine conflict instead of guessing.
Generate entity ids on the device too. UUIDv7 sorts by creation time, so a record created offline has its final identity immediately. Server-assigned ids force a mapping table and a class of bugs where a local child points at a parent that does not exist yet.
Drain the outbox in order, per entity, and stop the chain on a hard failure:
export async function drainOutbox(signal: AbortSignal) {
const now = Date.now();
const batch = await db.all<OutboxRow>(
"select * from outbox where next_attempt_at <= ? order by created_at limit 25",
[now],
);
for (const row of batch) {
if (signal.aborted) return;
try {
const res = await api.push(row, { idempotencyKey: row.id, signal });
if (res.status === "conflict") {
await resolveConflict(row, res.server);
} else {
await applyServerEcho(row.entity, res.record);
}
await db.run("delete from outbox where id = ?", [row.id]);
} catch (err) {
if (isValidationError(err) || isAuthzError(err)) {
await moveToDeadLetter(row, err); // can never succeed, so surface it
continue;
}
const attempts = row.attempts + 1;
const backoff = Math.min(2 ** attempts * 1_000, 5 * 60_000);
const jitter = Math.random() * backoff * 0.3;
await db.run(
"update outbox set attempts = ?, next_attempt_at = ?, last_error = ? where id = ?",
[attempts, now + backoff + jitter, String(err), row.id],
);
if (isOffline(err)) return; // no point burning the rest of the batch
}
}
}
The dead-letter path is not optional. A write that can never succeed, say an update to a job the server says someone else closed, has to become something the user can see and act on. Dropping it silently is how field staff lose an hour of work and start keeping paper backups.
Choosing a conflict strategy, honestly
Conflicts are rare and expensive. Pick the cheapest strategy that is actually correct for that entity.
Last-write-wins, compared on a server-assigned version or a hybrid logical clock rather than device wall clock. Phone clocks are wrong, sometimes by hours, and one misconfigured device can silently win every conflict for a week. LWW is right where a record has a single owner at a time: a technician's notes, a draft, a preference. It loses data by design, so use it only where losing the older edit is correct.
Per-field merge. Because the outbox stores changed fields rather than whole rows, you can apply only the fields the device touched and keep the server's value for the rest. Two people editing the same job, one setting the status and the other adding a phone number, both succeed. This covers the large majority of real conflicts in line-of-business apps, for the cost of a per-field timestamp. It is our default.
CRDTs. Justified when several people edit the same content concurrently and character-level or set-level convergence is a requirement: shared notes, a collaborative checklist, tag sets. Automerge and Yjs do it correctly, and you pay in bundle size, document growth, and a mental model most teams find hard to debug. We use them for one document type inside an app, never for the whole data layer.
For a small class of high-value conflicts, a fourth option is to show both versions and let a human decide. Whichever you pick, deletes need tombstones with a retention window, or a record deleted on the server reappears the moment an offline device pushes its stale copy.
Background sync
Foreground sync on connectivity change and on app resume covers most of the need. @react-native-community/netinfo tells you when the transport changed; treat that as a hint, and let a failed request be the real signal. expo-background-task, or WorkManager and BGTaskScheduler natively, handles the rest, with realistic expectations: iOS decides when your task runs and it may be hours later.
Practical rules:
- Cap batch size so an app resumed with 400 queued writes does not churn for two minutes before the first screen paints.
- Push photos and other large payloads through a separate lane with resumable uploads, so one 8MB image on a bad connection cannot block 30 status updates.
- Show queue state in the UI: pending change count and the time of the last successful sync. Users tolerate offline gracefully when the app is honest about it.
Test the failure paths deliberately
The happy path gets tested by accident. The failure paths need intent:
- Kill the process mid-drain. The outbox row must survive and the operation must not apply twice.
- Drop the response after the server committed. Force a timeout on a request the server processed, retry, and assert exactly one record exists. The most valuable test in an offline app.
- Clock skew. Set the device clock two hours back and run a conflict scenario. If the past wins, your ordering depends on device time and needs fixing.
- Long offline period. Queue 200 mixed operations across a simulated week offline, reconnect, and assert the final state matches a server-side replay of the same intents.
- Reordering. Shuffle the batch in a test build. Intent-based writes survive it; whole-object writes do not.
Run the drain logic in plain Node against in-memory SQLite so these stay fast unit tests rather than device automation. Keep a smaller set of Detox or Maestro flows for airplane-mode journeys through the real UI.
How we work
On mobile builds we settle the sync model in the first week, with the client, because it shapes the API as much as the app. That means naming the conflict strategy per entity in writing, agreeing what a stale write should do, and building the outbox and its dead-letter surface before the second screen exists. Failure-path tests land with the first sync code, not after the first support ticket. It is the difference between an app field staff rely on and one they work round.