← Mobile System Design: The 45-Minute Playbook

Offline-first and sync, properly

Lesson 2 of 42 min read

Single source of truth, the outbox pattern, idempotency and conflict policy.

Offline-first is one rule with three consequences.

The rule: the UI reads only from the local database. The network writes to the database. There is no code path where a network response goes straight to a screen.

fun notes(): Flow<List<Note>> = dao.observeAll()     // the only thing the UI touches

suspend fun refresh() {
    val fresh = api.notes()
    dao.upsertAll(fresh.map { it.toEntity() })        // the DB emits; the UI follows
}

Consequence 1: writes need an outbox

A write goes to the database immediately with a pending status, and a background job drains the queue. The user sees their change instantly and does not care whether the request has left the device.

  • Every row has a client-generated id. The server id arrives later and cannot be the primary key.
  • Statuses: pendingCreate, pendingUpdate, pendingDelete, synced. Deletes are soft until confirmed.
  • A WorkManager job with a network constraint and exponential backoff drains it, in order.

Consequence 2: retries must be idempotent

The dangerous case is not a failed request — it is a successful request whose response was lost. Retry it naively and you have created two orders. Send the client id as an idempotency key so the server can recognise the repeat and return the original result.

Consequence 3: you need a conflict policy

Two devices edited the same record. Pick one and name its cost:

  • Last-write-wins on a server timestamp — simple, and silently loses an edit. Fine for a settings toggle.
  • Server wins — predictable, but the user's offline work vanishes. Acceptable for read-mostly data.
  • Field-level merge — good for forms where people edit different fields.
  • Keep both and ask — safest for documents, needs UI.

Never use the device clock for ordering — clocks are wrong and users change them. Use the server's timestamp or a version counter.

Staleness

Store fetchedAt per collection and refresh when it is older than a threshold, not on every screen open. "Cached but refreshed in the background if older than five minutes" is a complete, defensible answer.