Field-service, logistics and retail apps cannot assume a connection. Technicians work in basements, drivers pass through dead zones, and store Wi-Fi drops at the worst moment. An offline-first app treats the network as an optimisation: the local database is the source of truth for the UI, and a sync engine reconciles it with the server whenever it can.
Local-first storage
Read and write against an on-device database (SQLite through a library such as op-sqlite or WatermelonDB) rather than an in-memory cache. The UI subscribes to local queries, so it updates instantly whether or not the server has seen the change yet.
Writes go through an outbox
Every mutation is written to the local table and appended to an outbox in the same transaction. Each entry carries a client-generated ID that the server uses as an idempotency key, so retries can never create duplicates.
type OutboxEntry = { id: string; entity: 'job' | 'note'; op: 'upsert' | 'delete'; payload: unknown; createdAt: number; attempts: number };
export async function flushOutbox(api: Api) {
const pending = await db.outbox.oldestFirst(50);
for (const entry of pending) {
try {
await api.applyMutation(entry); // server applies idempotently by entry.id
await db.outbox.remove(entry.id);
} catch (err) {
await db.outbox.bumpAttempts(entry.id);
if (isRetryable(err)) break; // keep order; retry on the next connectivity event
await db.outbox.moveToDeadLetter(entry.id, String(err));
}
}
}
Pull with a stable cursor
Fetch server changes since the last cursor. A timestamp alone is not enough — two records can share one — so use (updatedAt, id) as the cursor and sort by both.
// Server: changes after the client's cursor, in a stable order
const changes = await Job.find({
tenantId,
$or: [{ updatedAt: { $gt: since.at } }, { updatedAt: since.at, _id: { $gt: since.id } }],
}).sort({ updatedAt: 1, _id: 1 }).limit(500).lean();
Deletes are sent as tombstones (a deletedAt field) so offline devices learn about them; purge tombstones only after every device has synced past them.
Decide conflict rules per field
There is no universal conflict strategy. Write the rule down for each entity:
- Server-authoritative: prices, permissions, assignment — the server value always wins.
- Last-writer-wins per field: notes, checklists — merge field by field using timestamps from a trusted clock.
- Append-only: activity logs and photos — never conflict; both sides keep everything.
Trigger sync sensibly
Sync on app foreground, on connectivity regained (via NetInfo) and after local writes with a short debounce. Background execution on iOS is opportunistic, so never rely on it for correctness — only for freshness.
Field note: show sync state honestly in the UI. A small "3 changes waiting to upload" indicator prevents more support tickets than any retry algorithm.
Key takeaways
- Make the local database the UI's source of truth.
- Queue writes in an outbox with client-generated idempotency keys.
- Pull with a compound
(updatedAt, id)cursor and propagate deletes as tombstones. - Define conflict rules per entity and field.
- Surface sync status to users.