The 100 problems, solved / Collaboration and productivity / 055 · Design an offline-first client that syncs and resolves conflicts

Design an offline-first client that syncs and resolves conflicts

Edit on a plane, sync on landing, and merge with what the other device did meanwhile.

Concepts this design leans on

01The design, in the six-step framework

1. Requirements

The prompt is usually framed around notes or tasks: a user edits on a phone with no signal, then on a laptop, and both must end up with the same data without losing either side's work. The interesting parts are the sync protocol and the conflict rules, not the UI.

Functional

  • Create, edit and delete items (notes with title and body, tags, done flags) on any device while offline.
  • When a device reconnects, it uploads its changes and downloads everyone else's; the result converges on all devices.
  • Conflicts on the same field are resolved automatically where safe, and surfaced to the user where not.
  • Deletions propagate and never resurrect.
  • Out of scope: real-time co-editing of one body (that is Google Docs), end-to-end encryption.

Non-functional

  • Scale: 100 million users, 3 devices each, 50 items changed per user per day; 10 million syncs an hour at peak.
  • Latency: a sync of 100 changes completes in under 2 seconds on a mobile connection.
  • Consistency: eventual across devices; a device always sees its own writes immediately.
  • Correctness: no lost update, no resurrected deletion, convergence regardless of sync order.
  • Efficiency: sync traffic proportional to what changed, never the whole dataset.
Back of the envelope

100 × 106 users × 50 changes = 5 × 109 changes/day ≈ 58,000 writes/s average, ~300,000/s at peak

Each change ~500 bytes → 2.5 TB/day of change log; keep 90 days online = 225 TB

Writes are the heavy path, every write is appended once and read by two or three devices, so an append-only per-user log with a cursor is the natural shape.

2. Core entities

  • Item: item id (client-generated UUID), user id, fields (title, body, tags, done), per-field version vectors or timestamps, deleted flag (tombstone).
  • Change: user id, sequence number in the user's log, device id, item id, field-level patch, device logical clock, server receive time.
  • Device: device id, user id, last pushed clock, last pulled sequence number.
  • Conflict: item id, field, the two candidate values and their origins, shown to the user.

3. API

  • POST /sync/push with device id and a batch of changes, each carrying the item's base version the device last saw; returns per-change accepted, merged, or conflict, plus the new server sequence numbers. Idempotent on (device id, device sequence).
  • GET /sync/pull?since=cursor&limit=500 returns changes from the user's log after the cursor, in order, with the next cursor.
  • GET /items/{id} for a full read when a device needs to repair.
  • POST /conflicts/{id}/resolve with the chosen value.

4. High-level design

ClientsEdgeServicesDatapush / pullpush / pullappendapplywake other devicesPhonelocal DB + outboxLaptoplocal DB + outboxAPI gatewaySync servicemerge rulesChange notifierPer-user change log(append-only)Items storecurrent statePush (silent)
  1. Every edit is written to the device's local database first and appended to an outbox; the UI reads local data only, so offline and online feel identical.
  2. On connectivity, the device pushes the outbox in order. The sync service checks each change against the item's current version, applies the merge rule, appends the result to the user's log with the next sequence number, and updates the items store.
  3. The device then pulls from its last cursor, applies the remote changes locally, and moves the cursor. Pull after push means the device sees its own changes echoed with server sequence numbers and reconciles them.
  4. A silent push wakes the user's other devices so they pull within seconds instead of at the next app open.

5. Deep dives

How are conflicts detected, and which ones can be merged automatically?

Each change carries the base version of the item the device saw. If the server's current version equals the base, the change applies cleanly. If not, someone else changed the item meanwhile and the server compares at field level. Different fields changed by different devices merge trivially: title from the phone, done flag from the laptop. The same field changed on both sides is a true conflict. For a tags set, merge as a set union with explicit removals (add-wins observed-remove set, a small CRDT). For a boolean like done, last-writer-wins by device logical clock is acceptable. For the body text, do not silently pick one: keep the server value, store the loser as a conflict copy, and show it to the user. The rule table is the answer the interviewer is after: per field, union, last-writer-wins, or surface.

What clock decides "last writer" when device clocks are wrong?

Phone clocks drift and users change them. Use a hybrid logical clock: each change carries max(local wall time, last seen remote time) plus a counter, so causality is respected even when wall clocks disagree, and the server stamps receive time as a tiebreak. Never compare raw device timestamps. This is the clocks and ordering point interviewers probe; say that the server's sequence number is the only total order, and device clocks are used only to break ties among concurrent edits to one field.

How do deletions propagate without resurrecting?

A delete is a change like any other: it sets a tombstone with a version. A device that edits the item offline and syncs after the delete sees the tombstone's version is newer than its base; by the rule table, delete wins over edit (the edit is kept as a conflict copy the user can restore). Tombstones must outlive the slowest device: keep them for 90 days, and a device whose cursor is older than the retention window does a full resync instead of an incremental pull, which prevents a resurrected item from a device that missed the tombstone.

How does the log scale and how does a device catch up after a month?

The per-user log is sharded by user id and append-only, so writes are sequential and pulls are a range read by sequence number. Changes are compacted: once every device of a user has pulled past a point (the minimum device cursor), entries older than that are folded into the items store and dropped. A device returning after a month pulls in pages of 500 with the cursor; if the cursor is older than the compacted point, the server tells it to do a full snapshot download, which is 10 MB for a heavy user and finishes in seconds. All pushes are idempotent on (device id, device sequence) so a retried batch after a dropped connection never double-applies.

6. What a strong answer sounds like

  • Say that the client's local database is the source of truth for the UI and the server is a replicated change log; that framing removes most "what if offline" questions.
  • Give the per-field merge rule table and be honest about which conflicts a machine should not resolve.
  • Use a hybrid logical clock and the server sequence number; never raw device time.
  • Explain tombstones with a retention window and the forced full resync beyond it.
  • Follow-ups to expect: attachments (upload to object storage first, sync the reference), shared items between users (a shared log per item or per collection), and bandwidth (delta patches, compression, batching).
  • A common mistake is to design real-time collaborative editing when the question is about intermittent sync; the two have different consistency targets.

02Where this question was reported