Mobile architecture
Offline sync system design interview: what happens when two devices disagree
"Design offline sync for a notes app" is decided by one question: two devices edited the same thing while apart, and both synced. Which one wins, and who decided? A worked answer from the hiring side, for iOS and Android alike.
9 min read
Ask a room of mobile engineers to design offline sync and most of them reach for a library and a merge strategy within five minutes. Then I ask: the user edits the same note on a phone in the metro and on a laptop at home, and both sync an hour later. Which edit survives, and who decided that?
I have run 500+ technical interviews from the hiring side over 15 years in mobile. Offline sync is the prompt where "we use last write wins" sounds like an answer and is really the absence of one. Last write wins is a conflict policy too. It just lets a clock decide.
This is a worked answer, platform-neutral, because nothing in it depends on iOS or Android. For the platform rounds, see the iOS messaging answer and the Android field app answer.
- The prompt: design offline sync for a notes and checklists app, with several devices per user and shared lists.
- The shape: requirements, conflict cost per field, what you sync, cursors, detection versus resolution, deletes, resync, old clients, observability.
- The test underneath: can you say what a conflict costs, and who is allowed to settle it.
The 45-minute round, minute by minute
| Minutes | What you do | What the interviewer is checking |
|---|---|---|
| 0 to 5 | Clarify devices per user, sharing, offline duration | Do you ask before you build? |
| 5 to 12 | Requirements, and the cost of a conflict per field | Do you know the data matters differently? |
| 12 to 20 | What you sync, and the cursor | Where is data silently lost? |
| 20 to 30 | Detection, resolution, who decides | Is the policy a decision or a default? |
| 30 to 37 | Deletes, compaction, full resync | What happens after a month offline? |
| 37 to 45 | Old app versions, metrics, rollout | Have you run this in production? |
Most candidates spend the first 20 minutes on the transport, WebSocket or polling. That choice can change three times in an app's life. The conflict policy is what the round is about.
Requirements: the cost of a conflict, field by field
The question that earns the most points in the first ten minutes: what does it cost if two edits to this field collide? Answer it per field, because one sync engine carries data with very different stakes.
| Data | Cost of losing one side of a conflict | Policy |
|---|---|---|
| Note body text | Annoying: a paragraph needs retyping | Last write wins, ordered by server version |
| Checklist item done or not | Small, and easy to see | Last write wins per item, not per list |
| Shared list membership | Someone loses access, or keeps it | Server decides; the device only proposes |
| Anything with money or safety in it | Real harm | Never automatic: keep both, ask a person |
Write down what you will not build: the editor, real-time co-editing of one paragraph, the search index. Then say the per-field idea out loud. Consistency is a decision per invariant, not a database setting.
What you sync: snapshots, changes or operations
Three shapes, three different promises. A snapshot (the whole note as of version 47) is simple and turns every concurrent edit into a total conflict, because the server receives two full candidates and can only pick one. A change (this field became that) is smaller, but a naked change applied twice by a retry corrupts the record, so it needs an idempotency key. An operation log (who did what, in which order) keeps the intent a merge needs, and costs you compaction and replay.
A good answer mixes them on purpose: operations for the fields where the order of edits matters, snapshots with last write wins for the fields where a lost edit is acceptable. One engine, two policies, chosen by what a conflict costs.
Cursors: move them only when the data is stored
A device asks "what changed since I last looked?". The answer is a cursor, and cursors are where correct-looking sync quietly loses data. Use the server's version, which only goes up, never a timestamp from a device clock.
The rule that separates levels: store the records and move the cursor in one local transaction. Move the cursor when the response arrives, and an app killed before the write believes it is up to date while it is missing changes.
func apply(_ page: ChangePage, to db: LocalDatabase) throws {
try db.write { tx in
for change in page.changes {
try tx.upsert(change) // the records
}
try tx.setCursor(page.cursor) // and the cursor, in the same transaction
}
}Say the invariant so a test can check it: after any interruption, the stored cursor never points past a change the local database does not have.
Detection and resolution are different jobs
This is the structural move interviewers wait for. Detection is mechanical and knows nothing about notes: every edit carries the server version it started from, and if the record moved on since then, two edits diverged. Resolution is policy: what to do about it, decided by the people who know what a conflict costs.
sealed interface Outcome
data class Accepted(val newVersion: Long) : Outcome
data class Conflict(val current: Record, val incoming: Edit) : Outcome
// Detection, on the server. It knows nothing about what the record means.
fun detect(incoming: Edit, current: Record): Outcome =
if (incoming.baseVersion == current.version) Accepted(current.version + 1)
else Conflict(current, incoming)Resolution, per field class:
sealed interface Resolution
data class Winner(val value: String) : Resolution
data class NeedsReview(val candidates: List<String>) : Resolution
fun resolve(field: FieldClass, current: Record, incoming: Edit): Resolution =
when (field) {
// convergence is enough: the server's order decides, never a device clock
FieldClass.FREE_TEXT -> Winner(incoming.value)
// never automatic: keep both values, a person chooses
FieldClass.CRITICAL -> NeedsReview(listOf(current.value, incoming.value))
}Why Winner(incoming.value) for free text: the server applies edits in the order it accepts them, so the later accepted edit wins by the server's sequence. What must never decide is the device clock. A phone whose clock runs ninety seconds fast would let an old edit beat a newer correction.
The guarantees, in the user's words
- Read your own writes: the device that made an edit shows it at once, and never flickers back to the old value while it syncs.
- Never go backwards: once a device has shown version 48, a late response carrying 47 must not replace it. The cursor rule gives you this, as long as no code path writes records without checking versions.
- Converge: with no new edits, all devices reach the same value in bounded time. Last write wins gives you this, and nothing more.
A senior candidate states these three without naming a library. A staff candidate adds a number: how quickly a second device must reflect an edit while it is in the foreground.
Deletes: the edit that comes back from the dead
The user deletes a list on the phone. The delete syncs as a tombstone, a record that says "this is gone, as of version 49". A tablet that was offline for a month comes back holding version 47. If the server already threw the tombstone away to save space, the sync sees "the tablet has a list the server does not know" and helpfully uploads it again. The deleted list is back.
So tombstone lifetime is a correctness setting, not a storage one. It must outlast your longest realistic offline window, measured, not guessed. The same goes for compacting the operation log: you cannot throw away operations an old cursor still needs.
Full resync: the last resort, never the first
Past the compaction horizon, a returning device cannot catch up by replay. It has to discard its state and download a fresh copy. That is correct, expensive and visible to the user, so treat it as a metric, not a quiet fallback.
And one rule above all: send the device's pending edits before you replace its data. A full resync that throws away an edit the user made offline turns a slow sync into data loss. Pending edits go up first, conflicts get resolved by the policy above, and only then does the fresh copy come down.
Old app versions and the migration
You will not change the sync model on every device at once. Some users update next month. During the switch, the server accepts the old shape of writes from old versions and converts them into the new model, so its history stays coherent. That converter is temporary: give it an owner and a removal condition tied to how many old installs remain.
Observability and rollout
Name the numbers: conflicts per thousand edits, by field class; time for a second device to reflect an edit, at p50 and p99; full resyncs per thousand sessions; how long conflicts wait for a person to decide. Never log the contents of a conflict, only that it happened and for which field class.
Roll the new sync model out behind a remote flag, to internal users first, then a small share, and keep the old path readable so you can turn the flag off without losing anyone's data.
Mid, senior and staff: what the answers sound like
| Topic | Mid-level | Senior | Staff |
|---|---|---|---|
| Conflicts | "Last write wins." | Policy per field; server order, never device clocks. | Names who resolves which field class, and the metric that shows conflicts. |
| Cursor | "Fetch changes since last sync." | Server versions; cursor moves in the same transaction as the data. | States the invariant as a test and alarms on violations. |
| Deletes | "Delete it on the server." | Tombstones that outlast the offline window. | Measures the offline window and owns the tombstone lifetime. |
| Resync | "Just resync everything." | Last resort, after sending pending edits. | A metric with a budget, and a plan for old app versions. |
The staff column is not more components. It is the same design with owners, budgets and a way to know when it is wrong.
The follow-ups interviewers use to push
- "Both devices edited the same note offline. Which wins?" Tests policy per field, and server order.
- "The phone's clock is wrong." Tests whether a device clock ever decides.
- "The app is killed right after the sync response arrives." Tests the cursor transaction.
- "A tablet comes back after a month." Tests tombstones and compaction.
- "The server is too far ahead to replay." Tests resync that keeps pending edits.
- "Half the users are still on last year's version." Tests the migration path.
Prepare one sentence for each. If a follow-up makes you add a box, your design was missing a decision. If it makes you point at a box that already handles it, you are having the conversation the round is for.
Questions engineers ask about offline sync interviews
How do you answer "design offline sync" in a mobile system design interview?
Start with what a conflict costs for each kind of data, and who is allowed to resolve it. Then separate conflict detection from conflict resolution, use server versions as the cursor, move the cursor only when the data is stored, keep deletes as tombstones long enough for your longest offline device, and make a full resync the last resort, never the first.
Is last write wins wrong?
Not always. It converges, and for free text where a lost edit is an annoyance it is often the right choice. It is wrong when the data is important enough that someone must decide, and it is always wrong when the device clock decides which write is last.
Should I use CRDTs in the interview?
Name them as an option and say what they cost. They merge automatically, which is exactly what you do not want for a field where a human must choose between two values. Interviewers score the reason for the choice, not the acronym.
How should I practise this round?
Take one app with two devices per user, answer out loud against a 45-minute timer, then change one fact: one device was offline for a month, or the server compacted its history. Answer again. The second pass shows which parts of the design were decisions.
