Skip to main content

Offline-First Sync

Introduction

The last lesson ended in an airport lounge: a phone, a week offline, a queue of edits, and a sync about to happen. Every strategy you now own assumed the conflict was already found. This lesson is the machine that finds it — and it starts by renaming the situation.

A phone is not a client that occasionally loses its connection. It is a partition with a battery: tunnels, flights, dead spots, and a radio the operating system turns off to save power. Offline is not an edge case to handle; it's the default state of a pocket computer. In 2019, Martin Kleppmann and the Ink & Switch lab gave the resulting architecture its name in a manifesto titled Local-first software: you own your data, in spite of the cloud: the app reads and writes its own local store, instantly, always — and the network, when it appears, is an optimization. Every tap lands in milliseconds because it never leaves the device. What remains is the loop that reconciles your store with everyone else's, and that loop is this lesson.

A large phone in airplane mode holding a green local store (UI truth; taps: instant) and an amber durable queue with ops a1, a2, a3 — works in tunnels, planes, dead spots. A distant seq-numbered server sits across the canvas, joined by two dashed arcs: push ops outbound, pull greater-than cursor inbound. Law band: offline is not an error, it's the default; the network is an optimization.

The Loop, Station by Station

Seven stations, one cycle. Walk it once slowly; you'll run it in the lab.

The local store. The UI's only source of truth. Reads never wait on a network; writes commit here first. This is why the app feels instant on a plane.

The durable queue. Every local write also appends an operation — with an id minted at birth — to a queue that survives app kills and reboots. If the phone dies mid-flight, the edits are still there at breakfast. An op that only lives in memory is an op you've already lost.

The reconnect detector. Radio returns, a notification arrives, the user opens the app: something says now. Cheap, debounced, and allowed to be wrong — a false start costs a failed request, nothing more.

Push replay. The queue drains in order, each op carrying its id as an idempotency key, the discipline from Idempotency: Safe to Retry doing exactly what it was hired for. The server applies, records the id, acknowledges.

Pull with a cursor. "Give me everything after sequence 4732." The client never asks for everything; it asks for everything since. The cursor is the loop's memory of the other side.

Converge. Remote changes meet local state, and the strategy you chose per field — last lesson's whole discipline — earns its keep: union the tags, CRDT the counter, sibling the cart, refuse the seat.

Checkpoint. Both sides record how far they got. This station looks like bookkeeping. The next section shows it's the difference between a hiccup and starting over.

The sync loop as seven numbered stations on a racetrack, each station carrying inside its own box the failure it exists to absorb, shown in a red strip beneath its name. Top row, left to right: store (UI truth) absorbs no network; queue (durable ids) absorbs the app being killed; detect (now: sync) absorbs the loop never starting; push (keyed ops) absorbs acknowledgements dying in flight. The flow turns down the right side to the bottom row, read right to left: pull (after cursor) absorbs having to refetch everything; converge (the strategy) absorbs two writers changing the same thing; mark (checkpoint) absorbs a crash restarting from zero. A dashed line returns from mark to store, labelled again, forever. A legend states the convention once: red is what breaks. The closing band reads drop a station, its failure ships with you, and then the law: the loop is the product. Binding each failure to its own station shows that the seven stations are not a checklist someone invented but seven distinct failure modes, one per station.

Surviving the Half-Dead Sync

Now the part that separates toy sync from shipped sync. Your push is three ops deep when the elevator doors close. Op a1: sent, applied, acknowledged. Op a2: same. Op a3: sent, applied — and the acknowledgement dies in the tunnel with the connection.

What does the client know? That a3 is unacknowledged. What can it conclude? Nothing — an unacked op is applied-or-lost, and no amount of waiting distinguishes them. You've met this exact wall before: dead is indistinguishable from slow, wearing a mobile data plan. So the client does the only defensible thing: re-send everything unacknowledged. Which means the server will, sometimes, receive the same op twice — and that's why every op carries its id. Seen this id before? Drop it, re-acknowledge, move on. The counted-once law from the convergence contract, promoted from state merges to effects: a merge applied twice is harmless by algebra, but a 10expenseappliedtwiceisa10 expense applied twice is a 20 bug. Different law, same loop.

The pull side has its own half-death, and the fix is the checkpoint. This isn't theory: CouchDB's replication protocol — the same one PouchDB runs on phones — records a replication log on both source and target precisely so that a failed sync "can resume from last point of success, not from the very beginning." A week of changes, a crash at 90%, and the next attempt starts at 90%.

One more mercy: the queue can be compacted. Two hundred edits to the same field while offline don't need two hundred round trips; later ops that supersede earlier ones collapse before the push. The queue stores intent, not keystrokes.

The half-dead sync as a timeline. Ops a1 and a2 fly and are acknowledged; a3 is applied but its ack dies with the connection — the client cannot tell applied from lost. On reconnect it re-sends everything unacknowledged: with idempotency keys the server drops the duplicate a3 and the total stays exact; without keys a3 applies twice and the ledger quietly inflates to $40. Below, the pull side resumes at the checkpoint, from last point of success, never from zero. Law band: re-send everything, count everything once.

See It, Break It: The Sync Loop Lab

Below: a phone, a server, and everything this lesson claimed, live. Go offline, add a few expenses, and watch the queue mint op ids. Reconnect, sync, and read the sequence numbers and the checkpoint advancing. Then be the elevator: kill the sync mid-flight and sync again — first with idempotency keys on, watching the server drop the duplicate and keep the total exact; then with keys off, watching the same honest re-send quietly inflate the ledger. Finish on the pull side: add an entry from the other device and confirm the loop fetches only what's newer than the checkpoint, never the whole history.

A phone, a server, and the loop between them. Go offline and add expenses — each one lands instantly in the local ledger and mints an op id into the durable queue. Reconnect and sync: ops fly as packets, acknowledgements fly back, the server ledger grows sequence numbers, and the checkpoint chip advances on both sides. Then do the cruel part: kill the sync mid-flight, so an op is applied but never acknowledged, and sync again. With idempotency keys on, the server recognizes the op id and drops the duplicate: the total stays exact and EXACTLY ONCE stays green. With keys off, the re-send applies twice and you watch the total inflate. Add an entry from the other device and pull picks up only what's newer than your checkpoint. Lamps: IN SYNC and EXACTLY ONCE.

The Pocket's Rules

Four realities keep the loop honest on real devices.

The radio is expensive. Firing the loop on every keystroke murders the battery, so real clients batch: sync on reconnect, on charge, on push notification, on app-open. The queue's durability is what makes patience safe.

You don't sync the world. A phone holds the hot subset — recent notes, this month's expenses — and pulls the rest on demand. The cursor discipline works per collection, and "partial" is the only size that fits in a pocket.

Order the loop deliberately. Pull-then-push lets local ops rebase onto the freshest state; push-then-pull favors getting your writes durable first. Either is defensible; interleaving them carelessly is how carts duplicate.

Tell the user the truth. A pending badge, a synced tick, a conflict surfaced instead of swallowed. The UI is part of the protocol: an app that shows saved while ops sit in a dead queue is lying about durability, and users make real decisions on that lie.

The Bridge: Collaboration Is This Loop, Faster

Step back and look at what this section has quietly assembled. A local store, a durable queue, replay with dedup, a cursor, and a per-field merge strategy — that's not just the architecture of an offline expense app. It's the chassis under every collaborative product you use.

Google Docs is this loop running at millisecond cadence with transformed operations as the converge station. Figma is the loop plus server-ordered registers. Apple Notes is the loop at minutes, converging with self-merging types. Your shopping cart is the loop at hours, converging by union. The products feel wildly different; the machine is the same, tuned by two dials: how often the loop runs, and which strategy sits in the converge slot. Real-time collaboration is offline-first software with the offline shrunk to milliseconds — which is why this lesson, and not any single merge trick, is the real answer to "how would you design Google Docs?"

Four products drawn on one loop chassis, differing only in cadence. Google Docs: the loop at milliseconds, plus transformed ops. Figma: milliseconds, plus server registers. Apple Notes: minutes, plus CRDT types. A shopping cart: hours offline, plus union. Law band: collaboration is offline-first software with the offline shrunk to milliseconds.

Key Takeaways

  • A phone is a partition with a battery, so invert the architecture: the local store is the source of truth and the network is an optimization. That inversion has a name — local-first — and a manifesto worth reading.
  • The loop has seven stations: local store, durable queue, reconnect, push replay, pull-with-cursor, converge, checkpoint. Each one exists because a specific failure exists.
  • The half-dead sync is the exam: unacked is applied-or-lost, so re-send everything and dedup by op id. Counted-once, promoted from merges to effects — money is not a CRDT.
  • Checkpoints turn crashes into hiccups. Resume from the last success, never from zero; the shipped replication protocols have done it this way for years.
  • Collaboration is this loop at millisecond cadence. Two dials — frequency and converge strategy — span everything from a cart to Google Docs.

And that completes the working machinery of this section: conflicts chosen, types that merge themselves, operations that transform, strategies priced, and the loop that runs them from a pocket. What remains is to prove you own the judgment — four very different apps, each demanding its strategy, in Checkpoint: Collaborative & Replicated State.