Mobile system design · 14 min read

Offline-first mobile system design for the interview

How to design offline-first sync in a mobile system design interview: local source of truth, the outbox, conflict rules, clocks and migration on device.

This guide is for Senior and Staff mobile engineers who know how the system design round runs and want to be ready for offline-first mobile system design, the scenario the round keeps turning into: data that must work without a network and sync later. It covers what a strong answer says about the source of truth, the outbox, idempotency, conflicts, clocks and schema migration on device, with a worked design for a field inspection app and the follow-up questions the round asks, each with the answer that scores.

The general round and a simpler worked example (offline notes) are in the mobile system design interview guide. This page goes deeper, on a harder scenario.

Why offline-first is where the round goes

Whatever the prompt, chat, a feed, a to-do list, a delivery app, the interviewer will eventually take the network away. It is the fastest way to find out whether your design has a source of truth, whether writes survive the process being killed, and whether you have a rule for two devices disagreeing.

The recurring weak answer is a cache. “We store the last response and show it offline.” That makes reads work offline. It says nothing about writes, which is where the design is tested.

Offline-first is a promise the product and the sync protocol both have to keep. A cache does not make an app offline-first. Useful work happens locally, the server’s authority is explicit, every write is durable before the user is told it succeeded, and the devices eventually converge. If your answer does not cover all four, it is not offline-first yet.

Source of truth on the device

Split the problem in two before you draw anything. Showing data without a network and accepting changes without one fail in different ways, so treat them as two problems.

  • Reading offline is a freshness problem. The UI reads from a local store, and the network keeps the store current when it can. The worst failure is stale data: annoying, and recoverable. The design needs a freshness rule per screen and an honest empty state for data that was never downloaded, which is a different fact from “there is no data”.
  • Writing offline is a durability and authority problem. The promise is that the user’s work reaches the server eventually, once, in order, under their identity, even across a process kill. The worst failure is losing the work silently, and no retry can fix that: the data no longer exists anywhere.

Say both invariants out loud, separately. A design where only reads work offline is incomplete, however good the list screen looks.

The outbox and idempotency

The write path is where the round is won, and the order of operations is the whole point:

  1. Write the change to the local database and a row in an outbox table, in one transaction.
  2. Only after that commit, show the user it is saved.
  3. A separate worker that outlives the screen sends outbox rows when the network allows: WorkManager on Android, a background task or a background URLSession for uploads on iOS.

Get that order backwards, a confirmation rendered from UI state while the upload is still in flight, and a process kill loses the work while the screen said “saved”. That is the bug this whole design exists to prevent.

Two details separate a senior answer here:

  • The idempotency key is minted on the device, and stored in the outbox row. A retry after an ambiguous timeout (did the server get it or not?) sends the same key, and the server deduplicates. A key generated by the server cannot do that, because the client does not have it yet when the request is lost.
  • The outbox is a scheduler, not a list. Small critical records should not wait behind large photo uploads, so use two lanes. Several edits to the same draft before it syncs can be compacted into the final state. A pending operation needs a cancelled state. And an operation that fails permanently must surface to a person, never retry forever in silence.

Conflicts: last write, merge, or ask

The interviewer’s next move is usually the same: “Two devices edit the same record while offline. Both come online.”

The senior move is to make conflict policy per field, not per database. The same sync engine can carry data where a lost edit is an annoyance and data where it is a safety or money problem.

Policy When it is right What it costs
Last write wins, ordered by the server Free text where a lost keystroke is acceptable Silent loss of the other edit
Keep both Documents where no automatic merge is safe User friction, a simple build
Field-level merge Records whose fields are edited independently Needs per-field versions
Ask a person Values where a wrong automatic choice is dangerous A review state and a UI for it
CRDT Real-time collaboration on the same content The highest complexity; rarely justified in version one

Then separate detection from resolution. Detection is mechanical: two writes target the same record and neither was based on the other’s version. It belongs to the sync layer and is the same for every data type. Resolution is domain policy, and it belongs to the people who understand what a conflict costs. A design that fuses the two tends to reach for “just re-download everything” as its answer to every confusing conflict, which is expensive on a bad connection and still resolves by accident.

Clocks you cannot trust

Device clocks are wrong, sometimes by minutes. A design that orders offline edits by device timestamp will, at some point, keep the older value. Walk through it in the interview: one device’s clock is fast, its earlier edit carries a later timestamp, and last-write-wins silently keeps the wrong one.

What to use instead:

  • A server-assigned version per record as the order and as the sync cursor. The client stores the highest version it has durably applied and asks for everything above it.
  • Advance the cursor only after the records are written locally, in the same transaction. If the cursor is saved on receipt and the process dies before the write, the client believes it is current while missing data. Every component looks correct, and the end-to-end journey is wrong. That is the bug that hides best.
  • Tombstones for deletes that outlive the longest offline window. If the server forgets a delete too early, a device that was offline for a week re-uploads the deleted record and it comes back.

Migrating a database on a million phones

The follow-up: “You need to change the schema. What happens to users on the old version?” This is a Staff question. Senior and Staff candidates usually draw the same boxes. The difference shows in how they handle migration, several app versions live at once, and backing a change out.

Three things are true on a phone that are not true on a server:

  • You cannot run the migration yourself. It runs when each user opens the new version, on their device, possibly with a low battery and a full disk. It must be safe to interrupt and safe to retry.
  • Several app versions are live at once. Users on the old version keep syncing long after the release. The server must accept both the old and the new shape during the transition, and the design must say which component translates between them.
  • Pending outbox rows were written in the old format. They have to be migrated with the database or sent in their original shape, and the server has to accept them either way.

The shape that survives interruption is expand, migrate, contract, spread over several releases. Expand: add the new table, keep the old one, write to both. Migrate: backfill in the background in small batches, each batch committing its rows and a progress watermark in one transaction, so a killed process resumes where it stopped. Contract: drop the old structure in a later release, once telemetry shows the backfill finished for that cohort. The trap is one large transaction around the whole migration because it feels safe. On SQLite it holds the write lock throughout, grows the journal on a phone that may be nearly full, and a kill rolls all of it back, so the user starts from zero on every launch. Users on much older schemas also update straight to the newest, so every step must compose and resume.

A strong answer names the transition state, not only the start and end state: which versions coexist, what the server accepts from each, who owns the temporary compatibility code, and the condition under which it is removed (“when the old version falls below a threshold of active devices”). The Staff mobile system design guide covers why migration and ownership separate Staff answers from Senior ones.

Worked example: a field inspection app

The prompt: “Technicians inspect equipment at remote sites, often underground with no signal. They fill in a checklist, record readings and take photos. Design the app.”

Requirements worth asking about. Can two technicians inspect the same asset on the same day? (Yes.) How large are the photos? (Several megabytes each.) How stale may the job list be? (Same-day reassignment is rare.) Is there a regulatory record at the end? (Yes, so the server decides what is accepted.) Are the devices managed, so some cannot update? (Assume yes.)

The design, in the order a strong answer gives it:

  • Reads. The job list and asset history live in a local database, stamped with the time and server version they were fetched. The job list may be a day old with a “last updated” line. Asset history that is older than the asset’s last known service shows with a caution, not as if it were current. An asset never downloaded shows “not downloaded, connect to load”, never an empty history.
  • Writes. Submitting an inspection writes the record, references to the photo files on disk, and an outbox row with a device-minted idempotency key, in one transaction. Only then does the badge say “saved on device”.
  • Delivery. A worker that survives process death sends the outbox in two lanes: inspection records first, photos behind them. An inspection can be accepted with its photos marked as still uploading.
  • Identity. Each outbox row records who created it. If the session expires before the device finds signal, the worker refreshes the credential but keeps the original author, and a row whose author can no longer be authenticated goes to a failed state that someone follows up.
  • Status the user can trust. Pending (on device), uploading, accepted (by the server), failed (needs attention), conflict (needs review). A badge only claims what the device has already recorded, never what it expects the network to do.
  • Conflicts. Two technicians writing status to the same asset do not resolve by clock. The record enters review with both versions kept, and a supervisor decides. Free-text notes can use server-ordered last write wins.
  • Observability. The fraction of inspections started offline that reach accepted, outbox age, time from local save to server acceptance, permanent failure rate, conflict rate, each by app version. A drop in the first number means work is being lost.
  • Rollout. The new durable submit path behind a flag, one region first, and the server still accepting the old single-shot submit from devices that cannot update.

What the interviewer writes down is not the boxes. It is that the confirmation depends on a committed transaction, that the key is minted on the device, that conflict policy differs by field, and that old versions were part of the plan.

Questions this round asks, and the answers that score

Question Answer that scores The trap
“The app is killed right after the user taps Save.” The write and the outbox row were committed before the confirmation, so nothing is lost; the worker sends it on the next opportunity Saying the upload “retries” when the retry lived in the killed process
“The request timed out. Did the server get it?” You do not know, so you resend with the same device-minted idempotency key and the server deduplicates Generating a new request id on retry
“Two devices edited the same record offline.” Detection by version, resolution by field policy; name which fields merge and which go to review Last write wins by device timestamp
“Why not order by timestamp?” Device clocks drift; order by a server-assigned version “We sync clocks with NTP”
“A user deleted an item, another device was offline for a month.” Tombstones kept longer than the longest realistic offline window Deleting the row on the server immediately
“How do you sync in the background every few minutes?” Neither platform guarantees a schedule; sync on foreground, on connectivity, and opportunistically in the background Promising a fixed interval
“The outbox has a 40 MB video in front of a 2 KB form.” Separate lanes for critical records and bulk uploads One FIFO queue
“The session expired while items were queued.” Refresh the credential without changing who authored the item; escalate when the author cannot be re-authenticated Sending queued items under whoever is logged in now
“You changed the schema. What about users on the old version?” Name the transition: both shapes accepted, an owner for the compatibility code, a removal condition Assuming everyone upgrades
“The migration takes minutes on some phones.” Small batches that commit a durable watermark; expand, migrate, contract across releases One transaction around the whole migration
“How do you know it works in production?” Offline completion rate, outbox age, convergence time, permanent failures, by app version “We look at crash reports”

Use the table as a drill: cover the middle column, answer each question out loud, then check what you left out. The trap column is what the interviewer is listening for.

Sources

What to do next

Free guide: the Phoenix Method