Offline-first sync
Offline support is not a feature bolted onto a networked app. It is a different ownership model: the device owns the work until the server acknowledges it. Retrofitting is expensive, so decide early.
1. Write locally first, always
Every user action commits to local storage before anything is attempted over the network. The network call is a consequence of the local write, never a precondition for it.
user taps -> local store (durable) -> UI updates -> queue entry -> sync attempt
Not:
user taps -> POST -> wait -> on success, update UI <- fails in a lift
The test for whether you got this right: turn the radio off mid-session, force quit the app, reopen it. Everything the user did should still be there. Not "most of it" — a field worker who loses one form stops trusting the app and starts keeping paper.
2. Name the writer, and prefer one
Most conflict misery comes from not having decided who owns a record while it is in flight. The cheap, robust answer for form-and-checklist style apps is single writer: the device that started the draft owns it until submission, and the server does not modify it.
That is a real constraint, not a deficiency — but write it down, because the first feature request that breaks it (a reviewer editing someone's in-progress form from the web) needs a conflict model you do not currently have.
If you do need multiple writers, choose the model explicitly and up front: last-write-wins per field with timestamps, an append-only log of operations, or CRDTs. Each has a different data shape. You cannot retrofit one onto a design that assumed a different one.
3. Binary files need their own queue
Photos, audio, signatures and attachments behave differently from JSON records and should not share a queue with them:
- They are large, so they need progress, resumability and a size cap.
- They usually upload to a different place (object storage, a presigned URL) than the record does.
- They fail differently: the record can succeed while the photo is still uploading, which means the record has to reference a file that does not exist yet and the reader has to tolerate that.
- They fill the device. Cap the local cache, downscale on capture, and evict once uploaded and confirmed.
Store the local file reference in the record, swap it for the remote reference when the upload is acknowledged, and make the UI show the local copy until then so the user sees their own photo immediately.
4. Every queued operation carries an idempotency key
Generate the id on the device, at the moment of creation, and use it as the idempotency key for the whole lifetime of the record.
This one decision removes a whole category of bug. The retry after a timeout whose response was actually a success, the double-tap, the resumed upload — all of them collapse into the same request, and the server's job is just "create or return the existing one".
Never let the server allocate ids for offline-created records. A device-side temporary id that gets swapped for a server id means every local reference has to be rewritten, and the rewrite runs while new records are still being created.
5. "Synced" means acknowledged
Three states, not two:
| State | Meaning | Shown as |
|---|---|---|
local |
Written on device, not sent | "Saved on this device" |
pending |
Sent, no acknowledgement yet | "Sending..." |
synced |
Server confirmed and durable | "Saved" |
A request that has been sent is not saved. Do not delete the local copy, mark
the record green, or let the user leave a screen believing the work is safe
until the server has said so. If the acknowledgement never comes, the record
goes back to local and retries — it does not vanish.
6. Reconnect is a stampede
The moment coverage returns, every queued item on every device tries at once, often on a weak edge connection that dropped for a reason.
- Send serially per device, or with a small concurrency cap. Not all at once.
- Exponential backoff with jitter. Without jitter, every device on the site retries on the same tick.
- Cap the retry count, then surface the failure to the user rather than retrying forever in silence.
- Make sync resumable: it will be interrupted halfway through, routinely.
7. Trust the server's clock for ordering
Device clocks are wrong. They are wrong by minutes on cheap hardware, by hours after a timezone change, and by whatever the user set them to.
Keep the device time as captured_at — it is genuinely useful, it is what the
user experienced — but order, expire and reconcile on a server-assigned
timestamp. Anything security-relevant (token expiry, a window in which an action
is allowed, an audit trail) uses server time only.
8. Show the state, in the user's words
- A persistent, quiet indicator for offline. Not a toast that disappears before it is read.
- A count of what is waiting, and a way to see the list.
- A manual "sync now", because someone will need it and its absence reads as the app being stuck.
- Plain language for failure: what failed, whether the work is safe, and what to do. "Sync error" tells a person on a site with no signal nothing at all.
9. Testing it
Unit tests will not find these bugs. The ones that do:
- Airplane mode on for the whole flow, then on again.
- Toggle mid-submit, at the exact moment the request is in flight.
- Force quit with a full queue; reopen.
- Throttle to slow 2G rather than cutting the connection cleanly — half-open connections and timeouts behave differently from a clean offline, and are more common in the field.
- Fill the storage quota and keep going.
- Two devices, the same record, if you allow that at all.
Checklist
- Local write happens before the network call, durably
- Writer model chosen and written down
- Binary uploads in their own resumable, capped queue
- Device-generated ids used as idempotency keys end to end
- Three sync states; local copy kept until acknowledged
- Serial or capped sync with backoff and jitter
- Server time for ordering and expiry; device time kept as capture time
- Offline state and pending count visible; manual sync available
- Tested with airplane mode, force quit, throttling and a full quota