Offline-first sync
The mobile system-design interview as a lesson — local truth, the outbox, idempotency, and choosing a conflict story you can defend.
By the end you will be able to
- Structure the offline-first answer — local source of truth, read path, write path
- Implement the outbox pattern with idempotency keys and honest retry
- Choose and defend a conflict-resolution strategy per data type
The senior system-design prompt: "design a notes app that works on the subway." Candidates fail it in the first sentence. Which first sentence passes?
The architecture in one diagram
UI ──reads──▶ Local store (SwiftData/Core Data/SQLite) ◀──applies── Sync engine ◀──▶ Server
──writes─▶ │ the ONLY source of truth ▲
└────── enqueues ──▶ Outbox ──drains────┘
Three consequences fall out immediately, and each answers a standard interview follow-up:
- "What does the user see with no connection?" Everything — reads never touch the network. The
@Query-driven screens from the SwiftData lesson are already this shape. - "What happens when they edit offline?" The edit commits locally (UI updates instantly — optimistic by construction, not as a trick) and a pending operation lands in the outbox.
- "How does server data arrive?" The sync engine pulls changes and applies them to the local store; the UI updates because it observes the store — sync has no UI code.
The write path: the outbox
The outbox is the pattern name worth saying in the interview: every local mutation also appends an operation — not a snapshot — to a persistent queue:
struct Operation: Codable {
let idempotencyKey: UUID // minted once, at enqueue
let kind: String // "create-note", "edit-title", …
let entityID: UUID
let payload: [String: String]
let enqueuedAt: Date
}
A drain loop sends operations in order when connectivity allows, with the networking lesson's backoff. The design points interviews probe:
Idempotency keys. The drain sends op #7; the response times out. Did the server apply it? Unknowable. So the client must retry, and the server must treat the retried key as "already done — return the previous result". Without this, every timeout risks a duplicate note. You built this muscle twice already — SwiftData's .unique upserts and the notification identifiers — this is the same idea across the network boundary, and it is the single most important word in the sync interview.
Operations, not snapshots. "Set title to X" and "toggle starred" from two devices merge; two full-note snapshots fight. Fine-grained ops shrink the conflict surface before conflict resolution ever runs.
Ordering and dependency. "Edit note 7" must not be sent before "create note 7" — FIFO per entity is usually sufficient, and the server maps client-temporary IDs to real ones on create.
Walk the k2 story: the server applied the edit, the ack vanished, the client retried — and the idempotency key turned the retry into a harmless "already done". Delete the seenKeys check and the same trace applies the edit twice. That two-line difference is what "exactly-once effect from at-least-once delivery" means, and it is the mechanism behind every reliable sync system you have used.
The read path: pull, apply, converge
The sync engine pulls with a cursor — "changes since checkpoint X" (a server-issued token, not client clock time — device clocks lie: a phone five minutes fast asks "changes since 14:05" and the server's 14:02 edit is skipped, silently and forever). Applying changes reuses machinery you have: upsert by stable ID (SwiftData .unique), delete by tombstone. Two subtleties earn senior credit:
- Tombstones. The server cannot just omit deleted items from a delta — the client would keep them forever. Deletions arrive as explicit "id X was deleted" records.
- Echo suppression. Your own pushed edit comes back in the next pull. Applying it blindly is usually harmless (idempotent upsert) — but naive implementations that diff-then-notify can loop "remote change!" alerts on your own writes; tagging ops with a client/device ID closes it.
Conflicts: choose a story you can defend
Two devices edited the same note title while both offline. Someone must decide. The menu, in the order you should offer it:
| Strategy | How | Right for | Cost |
|---|---|---|---|
| Last-write-wins (LWW) | server timestamp/version orders writes; later wins | most fields, most apps | a lost edit, silently |
| Field-level merge | LWW per field — title from device A, starred from device B | structured records | more bookkeeping |
| Domain merge | type-specific rules — text merges, sets union, counters sum (CRDTs formalise this) | collaborative text, counters | real engineering |
| Surface to the user | keep both, ask | rare, high-stakes conflicts (documents) | UX cost; users hate it |
The senior move in the interview is scoping: "LWW per field for note metadata; the note body gets domain merging if collaboration is a feature, else last-writer-wins with version history as the safety net". Blanket answers — "CRDTs everywhere" or "just LWW" — read as inexperience in opposite directions.
The mechanism under all of them: the server keeps a version per record; every client edit carries the version it was based on. Version matches → clean apply, bump. Version behind → conflict path.
Field-level merge vs whole-record LWW, on the same concurrent edits. What survives?
struct Note {
var title: String
var starred: Bool
var version: Int
}
// Base record both devices synced at version 1:
let base = Note(title: "Groceries", starred: false, version: 1)
// Offline, device A renames; device B stars. Both based on v1:
let fromA = Note(title: "Groceries + pharmacy", starred: false, version: 1)
let fromB = Note(title: "Groceries", starred: true, version: 1)
// Strategy 1 — whole-record LWW, B arrives last:
var lww = fromA
lww = fromB
lww.version = 3
// Strategy 2 — field-level: keep each field that CHANGED from base.
var merged = base
if fromA.title != base.title { merged.title = fromA.title }
if fromB.title != base.title { merged.title = fromB.title }
if fromA.starred != base.starred { merged.starred = fromA.starred }
if fromB.starred != base.starred { merged.starred = fromB.starred }
merged.version = 3
print("LWW: \(lww.title) | starred \(lww.starred)")
print("merged: \(merged.title) | starred \(merged.starred)")Follow-up the interviewer always asks: "the user edits a note, backgrounds the app immediately, and the subway kills connectivity. What guarantees the edit eventually reaches the server?"
Drain with idempotency
Implement server-side idempotency and the client drain so lost acks never duplicate effects — the heart of the pattern.
Three-way field merge
Implement merge(base:mine:theirs:) — per-field three-way merge with theirs-wins only on a genuine same-field collision.
Run the full interview on yourself, out loud or on paper: "design offline-first for a shared grocery-list app (two family members, both often offline)." Cover: the diagram, what each edit enqueues, idempotency, the conflict story for check-off vs rename vs reorder (they differ!), and the one place you would accept silent data loss. Twenty minutes — the length of the real thing.
You can now:
- Open the system-design answer with the local-truth inversion and defend it
- Implement outbox + idempotency and explain the lost-ack scenario precisely
- Run a three-way field merge and say where it degrades
- Scope conflict strategies per data type like someone who has shipped sync
Next up: Keychain and security — where credentials live, and the storage questions that filter interviews.