Writing
Offline-first mobile: local SQLite, sync, and resolving conflicts honestly
Designing for a connection that comes and goes — a local database as the source of truth, an outbox that survives a crash, and the conflict resolution strategies that do not quietly lose somebody's work.
6 min read
Offline-first is usually described as a feature for people with poor connectivity. It is better understood as an architecture that stops treating the network as reliable — which is useful for everyone, because a train tunnel and a hotel wifi captive portal are the same problem as no signal at all.
We have built this for vehicle tracking and field operations, where the phone routinely leaves coverage mid-task. These are the decisions that mattered.
The local database is the source of truth
The defining commitment: the UI reads from local storage and never waits for the network.
UI ⇄ local SQLite ⇄ sync engine ⇄ APINothing in the UI layer knows whether a connection exists. It reads local rows and writes local rows; the sync engine reconciles in the background. That inversion is what makes the app feel instant online too, because no interaction is ever gated on a round trip.
Identifiers must be generated on the device
If the server assigns ids, nothing created offline has one, and every local reference is a placeholder that must be rewritten later. That rewriting is where the bugs live.
Use UUIDv7 or ULID, generated on the device
Both are time-ordered, so they index well as primary keys — unlike UUIDv4, which scatters writes across a B-tree.
The server accepts the client's id
It is the same record. A second, server-side id is a mapping table and a whole class of reconciliation bug.
Uniqueness is the client's responsibility, and that is fine
Collision probability at these sizes is not a real risk. Rewriting foreign keys after the fact is.
The outbox: every change is a durable record
Do not send mutations directly. Write them to a local queue, in the same transaction as the change itself.
CREATE TABLE outbox (
id TEXT PRIMARY KEY,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
operation TEXT NOT NULL, -- create | update | delete
payload TEXT NOT NULL,
base_version INTEGER, -- what the client believed when it wrote
created_at INTEGER NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);The transaction is the point:
db.transaction(() => {
db.run('UPDATE jobs SET status = ? WHERE id = ?', ['complete', jobId]);
db.run('INSERT INTO outbox (...) VALUES (...)', [...]);
});If the app is killed between those two statements — and it will be — either both happened or neither did. A change that is applied locally but absent from the outbox is a change that will never reach the server, and nothing will ever notice.
Sync: pull by cursor, push by queue
Pull changes since a server-issued cursor
Not a client timestamp. Client clocks are wrong, and a device whose clock drifts backwards will silently skip records forever.
Push the outbox in order, per entity
Ordering matters within an entity — create before update before delete. Across unrelated entities it usually does not, and parallelising there is a real speed-up.
Apply pulled changes in a transaction, then advance the cursor
Advance it only after the write commits, or a crash mid-apply loses a page of updates permanently.
Back off, and distinguish retryable from terminal
A 500 is retryable. A 422 is not — retrying it forever blocks the queue behind it, which is how one malformed record stops a device syncing at all. Terminal failures move to a dead letter table and surface in the UI.
Conflict resolution: pick a strategy per entity, and be honest about it
There is no general solution. There are four strategies, and the work is choosing the right one per entity rather than applying one everywhere.
| Strategy | Good for | What it costs |
|---|---|---|
| Last-write-wins | Status flags, settings, presence | Silently loses the other edit |
| Field-level merge | Records edited by different roles | Needs per-field versions |
| Append-only / event log | Notes, readings, measurements | No conflicts at all — different data model |
| Ask a person | High-value, genuinely ambiguous | UI work, and interrupts someone |
The append-only row is the one to reach for first. Most conflicts are an artefact of
modelling something as a mutable field that is really a sequence of events. A job with a
status column conflicts; a job with a list of timestamped status events does not — the
merge is a union, and the current status is a fold over it.
// Conflicting: two devices set status, one wins, one edit vanishes.
job.status = 'complete';
// Not conflicting: both events survive, order is by time, state is derived.
events.push({ jobId, type: 'completed', at: now(), by: userId });For the fields that genuinely are mutable, send the version the client was editing and let the server detect the conflict rather than guess:
PATCH /jobs/01J8X... If-Match: "v7"
→ 409 Conflict { current: { ... }, version: 9 }Tell the user the truth about sync state
Hiding sync state is a decision to have your users distrust the app. Three states, visible:
Saved locally
The default after any write. Say it plainly — the work is safe on this device.
Syncing / N pending
A count, not a spinner. People tolerate a queue they can see.
Needs attention
Terminal failures and conflicts awaiting a decision, reachable from one place.
Testing the parts that only break in the field
Kill the process mid-sync, repeatedly
The transaction boundaries above are only correct if you have actually tested this.
Test the captive portal, not just airplane mode
Airplane mode fails fast and cleanly. A hotel wifi that accepts the connection and returns HTML for every request is the case that breaks naive implementations.
Move the device clock backwards
If anything relies on client time, this finds it.
Sync the same account from two devices, offline, then reconnect both
This is the conflict path, and it is the one nobody tests until a customer reports it.
Fill the queue with hundreds of entries
Then reconnect. Backoff, ordering and batching all behave differently at that size.
Start here
Tell us what you are building
Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.