Field technicians, warehouses with poor coverage, training on a sports field, drivers on the road: internet fails exactly when users are capturing something important. The architecture I use is local-first: write to the device first, sync when possible.
The pieces
- Local replica (IndexedDB on web, SQLite on mobile).
- Outbox: a persistent queue of pending changes.
- Push the queue in batches.
- Pull what changed on the server since last time.
- Conflict resolution.
Every change is an operation with an identity
type Operation = {
operationId: string; // UUID generated on the device
entityType: string;
entityId: string;
action: 'create' | 'update' | 'delete';
payload: unknown;
baseVersion: number; // version the user had
clientTimestamp: string;
};
The operationId lets the server apply each operation exactly once, even if it arrives twice because the connection dropped right after sending.
An idempotent, honest server
Per operation, the server answers: applied (with the new version, or the stored response if replayed), rejected (invalid or forbidden, don’t retry), conflict (version changed) or retry (temporary failure). A rejected operation must not fail the whole batch, and later operations on the same record wait for the previous one.
Pull with a cursor
The server keeps a change log and hands the client an opaque cursor. The client applies each batch in a local transaction and only then advances the cursor. Deletions travel as tombstones.
Conflicts: decide per data type
Last write wins for preferences; merge when devices changed different fields; ask the user when the data matters. Showing a conflict beats silently losing data.
Details that matter
- One replica per user on shared devices.
- Visible pending / failed / conflict states.
- Sync on reconnect, on app foreground and periodically.
- A Service Worker so the app opens offline.
If your operation happens far from Wi-Fi, let’s talk.