# Offline First Sync

> Build or debug an app that has to keep working with no network — local writes, a queue, and reconciliation on reconnect. Use when adding offline support, when work is lost on a dropped connection, when sync produces duplicates or silently drops records, or when photos and other large files need to survive being captured out of coverage.

- Skill: `chinthakat/offline-first-sync` (Agent Skill)
- Install (CLI): `npx skillmds@latest add chinthakat/offline-first-sync`
- Raw SKILL.md: https://api.skillmd.com/api/skills/chinthakat/offline-first-sync/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: chinthakat (https://skillmd.com/u/chinthakat)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/chinthakat/offline-first-sync

---


# 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

