Skip to content
Commitwise
Menu
System Design

Mobile: offline-first and sync

Design a mobile app that works without a connection, with a local database as the source of truth, an outbox for writes, idempotent sync, cursors, tombstones and conflict resolution, with code in Kotlin, Swift and TypeScript.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

On the subway, a user writes a long note, taps Save, and the app shows “No connection” and throws the note away. Or it saves the note twice when the connection comes back. Or a note deleted on the phone reappears from the tablet a week later.

Mobile apps live on bad networks. An offline-first app treats the network as something that’s sometimes there, not as a requirement for every tap. This is a common mobile system design interview question, and an everyday engineering problem.

New to some of these? Open what you need, or skip ahead.

Basics:What are clients and servers?

A server is a computer (usually many of them) that runs your backend: it stores data and answers requests. A client is whatever sends the requests: your mobile app, a website, another server.

Big systems put many identical servers behind one address, with a load balancer in front that spreads requests between them. Data lives in a database, and frequently used data is often kept in a fast cache (in memory) so the database isn’t asked every time.

Basics:What are databases, tables and SQL?

A database stores an app’s data so it survives restarts and can be searched quickly. A relational database (PostgreSQL, MySQL) keeps data in tables: rows (one per book, user or order) and columns (id, title, year).

You talk to it with SQL: SELECT title FROM books WHERE year > 2000 reads, INSERT, UPDATE and DELETE change data. Rows refer to other tables through foreign keys (book.author_id points to a row in author), and indexes make lookups by a column fast.

Basics:What is an API?

An API (application programming interface) is the set of operations a system offers to other programs. In app development it usually means a web API: a server with URLs your app calls to read and change data, like GET /api/products to list products.

The API is a contract: the app relies on its URLs, fields and status codes. Changing them carelessly breaks apps that are already installed on people’s phones.

Basics:How do HTTP requests work?

Apps and servers talk with HTTP. A request has a method that says what to do (GET to read, POST to create, PUT to replace, DELETE to remove), a URL (https://api.example.com/books/42), headers (extra information, like who you are) and sometimes a body (the data, usually JSON).

The server sends back a response with a status code that says how it went (200 OK, 201 created, 404 not found, 500 server error), headers, and usually a body. Codes starting with 4 mean the client asked for something wrong; codes starting with 5 mean the server failed.

Basics:What is a cache?

A cache keeps a copy of data somewhere faster to reach than its original source. Instead of asking the database for the same popular product 1,000 times a second, the server keeps it in memory (or in a cache server like Redis) and answers from there.

Two questions come with every cache: how long may a copy be used before it might be out of date (its time to live, or TTL), and what happens when the original changes (the copy must be removed or updated).

Part Not started

Step 1: The local database is the source of truth

Think of a notebook you carry everywhere, and a filing cabinet at the office:

  • You always write in the notebook first, whether or not you’re at the office. Writing never waits.
  • You always read from the notebook: it’s with you, so it’s instant.
  • When you get to the office, you copy new pages into the cabinet, and copy back anything colleagues added there.

In the app, the notebook is a local database (Room on Android, SwiftData or SQLite on iOS, IndexedDB or SQLite in a web or React Native app), and the cabinet is the server. The UI only talks to the local database. A separate sync process moves changes between the database and the server when the network allows.

Offline-first inverts the usual data flow: the UI observes a local store that is the source of truth for display; writes are applied locally and recorded in an outbox of pending operations; a sync engine pushes the outbox (idempotently) and pulls remote changes since a server-issued cursor, applying them to the store. The hard parts are distributed-systems problems on unreliable links: idempotency under retries, ordering, deletes (tombstones), conflicts between devices and users, partial sync, and doing it all within the OS’s background execution limits.

Offline-first architecture

actionswriteobserveenqueuepushHTTPSapply pullsUIscreensRepositoryLocal databasesource of truthOutboxpending changesSync enginebackgroundSync APIServer database

Tap a box to see what it does.

@Model
final class Note {
    @Attribute(.unique) var id: UUID            // created on the device
    var text: String
    var updatedAt: Date
    var isDeleted = false                       // deletions are synced too
    var pendingSync = true

    init(id: UUID = UUID(), text: String) {
        self.id = id
        self.text = text
        self.updatedAt = .now
    }
}

@MainActor
final class NotesRepository {
    private let context: ModelContext
    private let sync: SyncEngine

    init(context: ModelContext, sync: SyncEngine) {
        self.context = context
        self.sync = sync
    }

    func save(text: String) throws {
        context.insert(Note(text: text))
        try context.save()                      // works offline: the note is already saved
        sync.requestSync()                      // runs now if online, or when the network returns
    }
}

// The UI reads only the local store:
// @Query(filter: #Predicate<Note> { !$0.isDeleted }, sort: \Note.updatedAt, order: .reverse) var notes: [Note]
@Entity(tableName = "notes")
data class NoteEntity(
    @PrimaryKey val id: String,                 // a UUID created on the device
    val text: String,
    val updatedAt: Long,
    val isDeleted: Boolean = false,             // deletions are synced too
    val pendingSync: Boolean = true,
)

class NotesRepository(
    private val dao: NoteDao,
    private val workManager: WorkManager,
) {
    fun notes(): Flow<List<NoteEntity>> = dao.observeVisible()      // the UI reads only this

    suspend fun save(id: String = UUID.randomUUID().toString(), text: String) {
        dao.upsert(NoteEntity(id = id, text = text, updatedAt = System.currentTimeMillis()))
        requestSync()                                                // works offline: the note is already saved
    }

    private fun requestSync() {
        val request = OneTimeWorkRequestBuilder<SyncWorker>()
            .setConstraints(Constraints(requiredNetworkType = NetworkType.CONNECTED))
            .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
            .build()
        workManager.enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, request)
    }
}
// A web or React Native app: IndexedDB through Dexie, the same idea as Room or SwiftData
interface Note {
  id: string;                                 // a UUID created on the device
  text: string;
  updatedAt: number;
  isDeleted: boolean;                         // deletions are synced too
  pendingSync: boolean;
}

const db = new Dexie("notes") as Dexie & { notes: EntityTable<Note, "id"> };
db.version(1).stores({ notes: "id, updatedAt" });

export const notes = () =>
  liveQuery(() => db.notes.filter((n) => !n.isDeleted).reverse().sortBy("updatedAt"));   // the UI reads only this

export async function save(text: string, id: string = crypto.randomUUID()) {
  await db.notes.put({ id, text, updatedAt: Date.now(), isDeleted: false, pendingSync: true });
  requestSync();                              // works offline: the note is already saved
}

function requestSync() {
  if (navigator.onLine) void syncOnce();      // and again on the "online" event and on app start
}

Three decisions make this work:

  • The id is created on the phone (a UUID), so a note has its identity before the server ever sees it, and the same note sent twice is still one note.
  • Saving never touches the network. The note is in the local database immediately, so the UI shows it and it survives the app being killed.
  • Sync runs when it can: WorkManager waits for a network connection and retries with backoff; on iOS the sync engine runs on launch, on network changes and in background tasks; a web app syncs on start, on the browser’s online event and with Background Sync where it’s supported.

Running on real phones

  • Background limits: Android runs sync through WorkManager (constraints, backoff, Doze-aware); iOS gives BGAppRefreshTask short, opportunistic windows and BGProcessingTask longer ones when charging. Silent push notifications can trigger a sync but are throttled. Sync on foreground is the one moment you can count on.
  • Schema migrations on the device must handle old local data and unsynced outbox entries from an older app version.
  • Security: the local database holds user data at rest; use the platform’s file protection or an encrypted store for sensitive data, and wipe it on sign-out.
  • Observability: log sync durations, failures and outbox size; an outbox that keeps growing on some devices is a bug report you’d otherwise never get.
  • Source of truth for UI: the local database (Room Flow, SwiftData @Query, IndexedDB liveQuery); the network refreshes it in the background.
  • Writes: one local transaction updates the row and adds an outbox entry; client-generated UUIDs.
  • Platform: WorkManager (network constraint, backoff); iOS BGAppRefreshTask/BGProcessingTask; sync on foreground, the one moment you can count on; migrate old outboxes; encrypt sensitive local data and wipe it on sign-out.

Check this part

  1. In an offline-first app, where does the UI read its data from?

  2. Why create ids on the device (UUIDs) instead of on the server?

  3. Why must the local change and its outbox entry be written in one local transaction?

  4. Which moment can a sync engine count on, across Android and iOS background limits?

The UI reads and writes a local database; a sync engine moves changes to and from the server when the network allows. Ids are created on the device, and saving never waits for the network.

Part Not started

Step 2: Push without duplicates

Spot the bugNotes that appear twice after a bad connection

Tap the line with the bug.

func pushPending() async throws {
    for note in try pendingNotes() {
        let created = try await api.createNote(text: note.text)       // POST /notes, server assigns the id
        note.serverID = created.id
        note.pendingSync = false
    }
    try context.save()
}
suspend fun pushPending() {
    for (note in dao.pending()) {
        val created = api.createNote(CreateNoteRequest(note.text))   // POST /notes, server assigns the id
        dao.markSynced(localId = note.localId, serverId = created.id)
    }
}
async function pushPending() {
  for (const note of await db.notes.filter((n) => n.pendingSync).toArray()) {
    const created = await api.createNote({ text: note.text });   // POST /notes, server assigns the id
    await db.notes.update(note.id, { serverId: created.id, pendingSync: false });
  }
}

Practice: a sync round, step by step

A sync engine runs the same loop every time it’s triggered:

  1. Push first: send pending local changes from the outbox in order, each as an idempotent request. Mark each one synced only after the server confirms.
  2. Pull next: ask the server for everything that changed since the last cursor it gave you: GET /changes?cursor=abc. The answer includes created, updated and deleted items, plus a new cursor.
  3. Apply in one local transaction: upsert changed items, delete the ones the server says were deleted, but don’t overwrite items that still have unsynced local edits (that’s a conflict to resolve).
  4. Save the new cursor in the same transaction, so a crash in the middle never skips changes.
  5. Repeat if the server says there are more pages, and schedule the next sync.

A screen that loads from the network directly breaks all of this:

Spot the bugAn offline notes app that needs the internet

Tap the line with the bug.

@MainActor @Observable
final class NotesModel {
    var notes: [NoteDTO] = []
    var error: String?

    func load() async {
        do {
            notes = try await api.notes()
        } catch {
            self.error = "You're offline"
        }
    }
}
class NotesViewModel(private val api: NotesApi) : ViewModel() {
    private val _state = MutableStateFlow<NotesState>(NotesState.Loading)
    val state = _state.asStateFlow()

    init {
        viewModelScope.launch {
            _state.value = try {
                NotesState.Loaded(api.notes())
            } catch (e: IOException) {
                NotesState.Error("You're offline")
            }
        }
    }
}
function NotesScreen() {
  const [notes, setNotes] = useState<NoteDto[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    api.notes()
      .then(setNotes)
      .catch(() => setError("You're offline"));
  }, []);

  return error ? <ErrorBanner text={error} /> : <NoteList notes={notes} />;
}

Exerciseeasy

An offline to-do app

Design the data layer of a to-do app that works fully offline: create, edit, check off and delete todos, on one device, syncing with a server account when online.

Describe: the local table’s columns, what happens on each user action, when sync runs, and the API endpoints you need.

›

Every row needs to know whether it has changes the server hasn’t confirmed, and deletions must be remembered until the server knows about them.

›

Two endpoints are enough: an idempotent PUT /todos/{id} for pushing (including deletions), and GET /todos/changes?cursor=… for pulling.

SolutionTry it yourself first

Local table todos

ColumnPurpose
id (UUID, primary key)created on the device; the same id on the server
title, isDone, positionthe data
updatedAtlocal edit time (for display and ordering)
serverVersionthe version the server last confirmed (null for new items)
isDeletedsoft delete: hidden in the UI, kept until the deletion is synced
pendingSynctrue when there are local changes the server hasn’t confirmed

Plus a sync_state table with the last cursor.

User actions (all local, instant):

  • create → insert with a new UUID, pendingSync = true;
  • edit or check off → update the row, pendingSync = true;
  • delete → isDeleted = true, pendingSync = true (the UI filters deleted rows);
  • each action then requests a sync.

Sync runs when the app comes to the foreground, after each change (debounced a few seconds), when connectivity returns, and periodically in the background (WorkManager periodic work, iOS BGAppRefreshTask):

  1. push every pendingSync row with PUT /todos/{id} (body includes isDeleted and baseVersion = serverVersion); on success store the returned version and clear pendingSync; rows confirmed as deleted are removed for real;
  2. pull GET /todos/changes?cursor=…, apply each change unless the local row has pendingSync = true, delete rows the server reports as deleted, and save the new cursor in the same transaction.

API

PUT /todos/4f6c…  { "title": "Buy milk", "isDone": true, "isDeleted": false, "baseVersion": 7 }
→ 200 { "version": 8 }                 409 if baseVersion is stale (another device changed it)

GET /todos/changes?cursor=c_1042
→ 200 { "changes": [ { "id": "4f6c…", "version": 8, "title": "…", "isDeleted": false }, … ],
        "cursor": "c_1050", "hasMore": false }

On a 409, a simple app can apply the server’s version and re-apply the local change on top, or for a to-do, merge per field (one device changed the title, the other checked it off: keep both). Even this single-user version already handles the essentials: no lost writes offline, no duplicates on retries, and deletions that propagate.

Delta sync, deletes and cursors

  • Cursors must be server-issued and opaque: a change-sequence number or log position, never the client’s timestamp.
  • Deletes need tombstonesTombstoneA marker that records a deletion, so other devices or replicas learn that the item was deleted.Open in glossary →: a deleted row is kept (or recorded in a change log) long enough for every device to learn about it. After the retention period, devices whose cursor is older must do a full resync.
  • Ordering: apply changes per entity in version order, and push an entity’s operations in the order they happened (create before update before delete).
  • Pagination and partial sync: page the change feed, and sync only what the device needs (the last 90 days, the user’s own projects); fetch the rest on demand.

The note that comes back

Predict the output

The sync API returns rows changed since the cursor, but deleted rows are removed from the server database. Phone A deletes note 7. Tablet B is offline, later syncs, then edits note 7 and pushes it. What happens to note 7?

A cursor made of timestamps

Spot the bugChanges that some devices never receive

Tap the line with the bug.

@GetMapping("/notes/changes")
fun changes(@RequestParam since: Instant): ChangesResponse {
    val rows = notes.findByUpdatedAtAfterOrderByUpdatedAt(since)
    return ChangesResponse(changes = rows, cursor = rows.maxOfOrNull { it.updatedAt } ?: since)
}

// updatedAt is set by the application when a write transaction starts
app.get("/notes/changes", async (req, res) => {
  const since = new Date(String(req.query.since));
  const rows = await prisma.note.findMany({ where: { updatedAt: { gt: since } }, orderBy: { updatedAt: "asc" } });
  res.json({ changes: rows, cursor: rows.at(-1)?.updatedAt ?? since });
});

// updatedAt is set by the application when a write transaction starts

  • Sync loop: push the outbox in order → pull changes since an opaque, server-issued, commit-ordered cursor → apply in one local transaction together with the new cursor.
  • Deletes: tombstones in the change feed; a full resync when a cursor is older than tombstone retention. Timestamp cursors skip late commits.
func syncOnce() async throws {
    for op in try outbox.pendingInOrder() {
        try await api.apply(op)                          // idempotent by op.id
        try outbox.markDone(op.id)
    }
    var cursor = try syncState.cursor()
    var page: ChangesPage
    repeat {
        page = try await api.changes(since: cursor)      // includes tombstones
        try store.transaction {
            for change in page.changes { try applyUnlessPendingLocally(change) }
            try syncState.save(cursor: page.cursor)      // same transaction: no skipped changes
        }
        cursor = page.cursor
    } while page.hasMore
}
suspend fun syncOnce() {
    for (op in outbox.pendingInOrder()) {
        api.apply(op)                                    // idempotent by op.id
        outbox.markDone(op.id)
    }
    var cursor = syncState.cursor()
    do {
        val page = api.changes(cursor)                   // includes tombstones
        db.withTransaction {
            page.changes.forEach { change -> applyUnlessPendingLocally(change) }
            syncState.saveCursor(page.cursor)            // same transaction: no skipped changes
        }
        cursor = page.cursor
    } while (page.hasMore)
}
async function syncOnce() {
  for (const op of await outbox.pendingInOrder()) {
    await api.apply(op);                                 // idempotent by op.id
    await outbox.markDone(op.id);
  }
  let cursor = await syncState.cursor();
  let page: ChangesPage;
  do {
    page = await api.changes(cursor);                    // includes tombstones
    await db.transaction("rw", db.notes, db.syncState, async () => {
      for (const change of page.changes) await applyUnlessPendingLocally(change);
      await syncState.saveCursor(page.cursor);           // same transaction: no skipped changes
    });
    cursor = page.cursor;
  } while (page.hasMore);
}

Check this part

  1. The app POSTs a new note, the server creates it, and the response is lost in a tunnel. The app retries. What happens, and what prevents it?

  2. When should the sync engine run?

  3. Why can't a deleted item simply be removed from the server database?

  4. Why should the sync cursor be server-issued and commit-ordered?

Push the outbox with idempotent requests (PUT with a client id, or an Idempotency-Key), then pull changes since an opaque, commit-ordered server cursor, including deletions as tombstones, and save the cursor in the same transaction.

Part Not started

Step 3: Whose change wins?

When two devices change the same note while offline, sync has to pick. The simplest rule is last write wins (LWW): keep the change with the later timestamp. But whose clock?

Predict the output

Ana edits a note on her phone at 10:00 (the phone's clock is 5 minutes fast, so it says 10:05). At 10:02 she edits the same note on her tablet (correct clock). Both sync later, using the devices' timestamps for last write wins. Which version is kept?

Conflicts: choose per field, not per app

  • Detect with versions, not clocks. Each write carries the baseVersion it was made on; the server rejects or merges when the current version differs. This is optimistic concurrency, the same idea as @Version on the server.
  • Last write wins (by server arrival order) is fine for low-value fields and single-user data.
  • Field-level merge keeps independent changes (title on one device, isDone on another) and only conflicts when the same field changed on both sides.
  • Domain rules beat generic ones: quantities in a shared cart add up, “checked” might win over “unchecked”, text can show both versions to the user.
  • CRDTs (conflict-free replicated data types: add-wins sets, counters, sequence types for text) merge automatically and converge without a central decision, at the cost of metadata and complexity. Useful for collaborative text and shared lists; overkill for most forms.
Exercisehard

A shared family shopping list

A family of four shares shopping lists. Each person can add, rename, check off and delete items, on phones that are often offline in shops with bad reception. When online, changes should appear on other phones within a few seconds. Two people sometimes add “milk” at the same time, or one checks off an item while another renames it.

Design the data model, sync protocol, conflict handling and real-time updates.

›

Look at each field separately: what should happen if two people change it concurrently? “Checked” and “name” may need different rules, and “add” vs “delete” needs a decision.

›

Real-time doesn’t require a different sync protocol. A notification (“list 12 changed”) can simply trigger the normal pull.

SolutionTry it yourself first
// Server-side model; the device keeps the same fields plus pendingSync and baseVersion.
data class ListItem(
    val id: UUID,                     // client-generated: offline adds are idempotent
    val listId: UUID,
    val name: Field<String>,          // per-field value + version + author
    val checked: Field<Boolean>,
    val position: Field<String>,      // fractional index ("a0", "a0V"): reorder without renumbering
    val deleted: Boolean,             // tombstone, kept 90 days
    val version: Long,                // increments on every change
)

data class Field<T>(val value: T, val version: Long, val changedBy: UserId)

// Push: operations, not whole objects, so concurrent edits to different fields both survive.
sealed interface ItemOp {
    val itemId: UUID
    data class Add(override val itemId: UUID, val name: String, val position: String) : ItemOp
    data class Rename(override val itemId: UUID, val name: String, val baseFieldVersion: Long) : ItemOp
    data class SetChecked(override val itemId: UUID, val checked: Boolean) : ItemOp
    data class Delete(override val itemId: UUID) : ItemOp
}

Sync protocol

  • POST /lists/{id}/ops with a batch of operations, each with a client-generated opId (idempotency: replayed ops are ignored). The server applies them in order per item, assigns new versions, and returns the resulting items.
  • GET /lists/{id}/changes?cursor=… returns changed items (including tombstones) and a new opaque cursor based on a per-list change sequence (each list is small, so a per-list sequence assigned under a row lock on the list is simple and commit-ordered).
  • Real time: devices with the list open hold a WebSocket (or SSE) subscription; the server sends “list 12 changed, sequence 1050”, and the device runs a normal pull. Devices in the background get a silent push or catch up on next foreground. No separate real-time data path to keep consistent.

Conflict rules, per field

SituationRuleWhy
Two people rename the same itemlast write wins by server order; the loser’s device shows “Ana renamed this to Oat milk”rare, low stakes, visible
One renames, another checks offboth apply (different fields)independent changes
Concurrent check/unchecklast write wins by server orderit’s a toggle; the latest intent is right
One deletes, another edits offlinedelete wins, edit is dropped, and the editor sees a “deleted by Marko” note; or restore on edit if the family prefersavoids zombie items; an explicit, visible rule
Two people add “milk”two items (different ids); the UI suggests merging duplicates with the same normalized namenever silently merge things users added on purpose

Device side: the local database (Room or SwiftData) is the UI’s source of truth; ops go to an outbox in the same local transaction as the change; WorkManager or iOS background tasks push and pull; on foreground and when a WebSocket notification arrives, sync runs immediately.

Things to call out: tombstones are kept 90 days, and a device whose cursor is older does a full list resync; ops are small, so a week offline in a basement is still a quick sync; all of this is per list, so families scale independently and a hot list can’t slow down anyone else. For collaborative text (a shared notes field with simultaneous typing), per-field LWW stops being acceptable, and a sequence CRDT (Automerge, Yjs) is the tool.

Deep dives

Deep diveA CRDT you can explain in an interviewInterview favorite

The grow-only counter (G-counter) shows how CRDTs work. Each device keeps a map from device id to the count that device has added: phone {phone: 3, tablet: 1}, tablet {phone: 2, tablet: 4}. Merging takes the maximum per device ({phone: 3, tablet: 4}), and the value is the sum: 7.

Merge is commutative, associative and idempotent, so devices can sync in any order, any number of times, and still converge, with no central server deciding. A counter that also decrements is two of them (increments minus decrements). Sets, maps and text sequences follow the same idea with more metadata, which is what libraries like Automerge and Yjs implement.

  • Conflicts: detect with versions (baseVersion), not device clocks; resolve per field (LWW by server order, merge, domain rules, CRDTs for collaborative text); make outcomes visible.
  • Real time: WebSocket, SSE or silent push as a trigger for the normal pull, never a second data path.

Check this part

  1. Why is "last write wins by device timestamp" risky?

  2. What does the server compare to notice that two devices edited the same note?

  3. One person renames an item while another checks it off, both offline. What's the best default?

  4. How should real-time updates relate to sync?

Device clocks can't order changes between devices. Detect conflicts with server versions, resolve them per field with domain rules and visible outcomes, and use real-time channels only as triggers for the normal pull.

Key takeaways

  • Offline-first: the UI reads and writes the local database; sync moves changes to and from the server in the background.
  • Create ids on the device and push with idempotent requests, so retries never duplicate.
  • Sync pushes pending changes, then pulls changes since a server cursor, including deletions.
  • Device clocks can’t be trusted to order changes between devices.
  • Use opaque, commit-ordered cursors and tombstones; a timestamp cursor eventually skips changes.
  • Detect conflicts with versions and resolve them per field, with domain rules and visible outcomes; CRDTs for collaborative text.
  • Treat real-time channels as triggers for the normal pull, not as a second data path.
  • Design for the platform: background execution limits, migrations of unsynced data, encryption and sync observability.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.