iosNudge

Design an Offline-First Notes App

HardSystem designPersistenceConcurrencyNetworkingArchitecture 60 min read

Requirements

You design a notes app for iPhone, iPad and Mac. It must work with no network at all. When the network is back, all devices sync. "Offline-first" means the local database is the main copy. The server is a way to share changes between devices.

Functional

  • Create, edit and delete notes, also offline.
  • Folders. Search across all notes.
  • Changes on one device show on the others after sync.
  • When two devices edit the same note, nothing is lost.

Non-functional

  • Never lose data. This is the top rule.
  • Instant UI: every read and write is local. No spinners while typing.
  • Eventually consistent: devices may differ for a while, but they end up the same.
  • Battery and data friendly: send only what changed.
  • Works with 10,000+ notes.

Out of scope

  • Live collaboration between different people (like Google Docs).
  • Rich text editor details, attachments, sharing.

Questions to ask the interviewer

  • One user with many devices, or shared notes between people?
  • How often do two devices edit the same note at the same time?
  • Is "last write wins" OK, or must we never drop an edit?
  • Do we need note history or undo across devices?
  • Apple platforms only? (Then CloudKit is an option.)

High-level architecture

+-------------+      +--------------+      +-------------------------------+
|  SwiftUI    |<---->|  NotesRepo   |<---->|  Local DB (SQLite / SwiftData)|
|  list +     |      |  read/write  |      |  notes, sync fields, cursor,  |
|  editor     |      |  locally     |      |  full-text search index       |
+-------------+      +--------------+      +---------------+---------------+
                                                           |
                                                  +--------v--------+
                                                  |   SyncEngine    |  (actor)
                                                  |  1. push dirty  |
                                                  |  2. pull since  |
                                                  |     cursor      |
                                                  +--------+--------+
                                                           | HTTPS
                                                  +--------v--------+
                                                  |   Sync server   |
                                                  |  versions +     |
                                                  |  change feed    |
                                                  +-----------------+

Sync triggers: app launch, app foreground, local edit (debounced),
network back (NWPathMonitor), silent push, background refresh task.
PartJob
UIReads and writes through the repo. Never calls the network.
NotesRepoSaves edits to the DB and marks them "dirty" (changed, not yet synced).
Local DBThe source of truth on the device.
SyncEngineSends local changes up (push), gets other devices' changes (pull), merges conflicts.
ServerGives each write a version number. Keeps a change feed ordered by version.

Data model

struct NoteContent: Codable, Sendable, Equatable {
    var title: String
    var body: String
    var folderID: UUID?
    var isDeleted: Bool           // a "tombstone": deletes must sync too
}

struct Note: Identifiable, Codable, Sendable, Equatable {
    let id: UUID                  // made on the device; no server round trip needed
    var content: NoteContent
    var updatedAt: Date           // for sorting in the UI only

    // Sync bookkeeping. Only on the device.
    var serverVersion: Int64?     // last server version we know; nil = never synced
    var base: NoteContent?        // content at the last sync: the "common ancestor"
    var localRevision = 0         // +1 on every local edit
    var syncedRevision = 0        // the localRevision we last pushed with success

    var isDirty: Bool { localRevision != syncedRevision }
}

struct ServerNote: Codable, Sendable {
    let id: UUID
    let content: NoteContent
    let version: Int64            // server counter, grows on every write
}

Why each field exists:

  • id is a UUID made on the device. Two offline devices can create notes without id clashes.
  • isDeleted keeps the row after a delete. Without it, other devices never learn about the delete.
  • serverVersion tells the server which version our edit was based on.
  • base lets us see who changed what when there is a conflict.
  • localRevision and syncedRevision handle edits that happen during a sync.

API / interfaces

Server endpoints

POST /v1/sync/push
  body:  { "changes": [ { "id", "content", "baseVersion" } ] }
  reply: { "accepted": [ServerNote], "conflicts": [ServerNote] }

GET  /v1/sync/pull?cursor=<opaque>&limit=200
  reply: { "changes": [ServerNote], "nextCursor": "...", "hasMore": true }

Server rule for push: if baseVersion equals the current version, save it and give it a new version. If not, someone else changed the note first. Reject it and send back the server copy. This check is called optimistic concurrency.

Swift protocols

struct PushChange: Encodable, Sendable {
    let id: UUID
    let content: NoteContent
    let baseVersion: Int64?
}

struct PushResult: Decodable, Sendable {
    let accepted: [ServerNote]
    let conflicts: [ServerNote]
}

struct PullResult: Decodable, Sendable {
    let changes: [ServerNote]
    let nextCursor: String
    let hasMore: Bool
}

protocol SyncAPI: Sendable {
    func push(_ changes: [PushChange]) async throws -> PushResult
    func pull(cursor: String?) async throws -> PullResult
}

protocol NotesStore: Sendable {
    func save(id: UUID, content: NoteContent) async throws      // local edit: revision += 1
    func dirtyNotes() async throws -> [Note]
    func markPushed(_ saved: ServerNote, revision: Int) async throws
    func resolveConflict(_ server: ServerNote, merged: NoteContent) async throws
    func applyPulled(_ notes: [ServerNote], newCursor: String) async throws  // one transaction
    func cursor() async throws -> String?
}

Deep dives

1. Local-first writes

Every edit goes to the database first. The UI reads from the database. The network is never on the path of a tap.

@MainActor
@Observable
final class EditorViewModel {
    var content: NoteContent
    private let noteID: UUID
    private let store: NotesStore
    private let scheduler: SyncScheduler
    private var saveTask: Task<Void, Never>?

    init(note: Note, store: NotesStore, scheduler: SyncScheduler) {
        content = note.content
        noteID = note.id
        self.store = store
        self.scheduler = scheduler
    }

    func textDidChange() {
        saveTask?.cancel()
        saveTask = Task {
            try? await Task.sleep(for: .milliseconds(500))   // save after typing pauses
            guard !Task.isCancelled else { return }
            try? await store.save(id: noteID, content: content)
            scheduler.noteDidChange()
        }
    }
}

Also save right away when the editor closes or the app goes to the background. Never keep unsaved text only in memory.

Database tips:

  • Use transactions. A crash in the middle must not leave half a change.
  • Use file protection completeUntilFirstUserAuthentication, so background sync can open the DB while the phone is locked.
  • Add a full-text index (SQLite FTS5) for fast offline search.

2. The sync engine: push, then pull

One sync run has two steps:

  1. Push: send all dirty notes with their baseVersion.
  2. Pull: ask for all changes after our saved cursor. Apply them.

Only one sync may run at a time. If someone asks for a sync during a run, run once more at the end.

actor SyncEngine {
    private let store: NotesStore
    private let api: SyncAPI
    private var isSyncing = false
    private var runAgain = false

    init(store: NotesStore, api: SyncAPI) {
        self.store = store
        self.api = api
    }

    func sync() async {
        if isSyncing { runAgain = true; return }
        isSyncing = true
        defer { isSyncing = false }
        repeat {
            runAgain = false
            do {
                try await push()
                try await pull()
            } catch {
                return        // offline or server error: the scheduler retries with backoff
            }
        } while runAgain
    }

    private func push() async throws {
        let dirty = try await store.dirtyNotes()
        guard !dirty.isEmpty else { return }

        let changes = dirty.map {
            PushChange(id: $0.id, content: $0.content, baseVersion: $0.serverVersion)
        }
        let result = try await api.push(changes)       // in real code: batches of ~100
        let sentRevision = Dictionary(uniqueKeysWithValues: dirty.map { ($0.id, $0.localRevision) })

        for saved in result.accepted {
            try await store.markPushed(saved, revision: sentRevision[saved.id] ?? 0)
        }
        for server in result.conflicts {
            guard let local = dirty.first(where: { $0.id == server.id }) else { continue }
            let merged = merge(base: local.base, local: local.content, server: server.content)
            try await store.resolveConflict(server, merged: merged)   // still dirty
            runAgain = true                                          // push the merged copy
        }
    }

    private func pull() async throws {
        var cursor = try await store.cursor()
        while true {
            let page = try await api.pull(cursor: cursor)
            // Save the changes AND the new cursor in one transaction.
            // If we crash, we pull the same page again. That is safe.
            try await store.applyPulled(page.changes, newCursor: page.nextCursor)
            cursor = page.nextCursor
            if !page.hasMore { break }
        }
    }
}

What applyPulled does for each server note:

  • Our own echo. If version <= serverVersion, we already have it. Skip.
  • Local copy is clean. Overwrite it. Set base and serverVersion from the server.
  • Local copy is dirty. Do not overwrite. The next push will get a conflict and merge. This keeps all conflict logic in one place.

Edits during a sync. The user types while a push is on the way:

push sends revision 5  ------------------>  server saves it as version 12
user types: localRevision = 6
reply arrives: markPushed(version 12, revision: 5)
  -> syncedRevision = 5, localRevision = 6  -> still dirty -> pushed next time

With only an isDirty flag, we would set it to false here, and revision 6 would never sync. This is a classic bug.

When to sync. Debounce local edits, so typing does not start 50 syncs:

@MainActor
final class SyncScheduler {
    private let engine: SyncEngine
    private var pending: Task<Void, Never>?

    init(engine: SyncEngine) { self.engine = engine }

    func noteDidChange() {
        pending?.cancel()
        pending = Task {
            try? await Task.sleep(for: .seconds(2))
            guard !Task.isCancelled else { return }
            await engine.sync()
        }
    }
}

Also sync on launch, on foreground, when NWPathMonitor says the network is back, on a silent push from the server ("something changed"), and in a BGAppRefreshTask.

3. Conflict resolution

A conflict happens when two devices edit the same note before they sync. You have four options:

StrategyHowGoodBad
Last write wins (LWW)Newest version winsVery simpleSilently loses an edit
Field-level mergeCompare each field with baseKeeps most editsNeeds base
Keep bothMake a "conflicted copy" noteNever loses dataUser must clean up
CRDTSpecial data types that always mergeMerges text by characterComplex, bigger data

For notes, a good answer is field-level merge, and keep both when the same field changed on both sides.

func merge(base: NoteContent?, local: NoteContent, server: NoteContent) -> NoteContent {
    if local == server { return server }       // same result: not a real conflict
    let base = base ?? server                  // never synced: take our changes
    var result = server

    if local.title != base.title { result.title = local.title }
    if local.folderID != base.folderID { result.folderID = local.folderID }

    let weChangedBody = local.body != base.body
    let theyChangedBody = server.body != base.body
    if weChangedBody && theyChangedBody {
        // Both changed the text. Never drop text: keep both versions.
        result.body = local.body + "\n\n--- From another device ---\n\n" + server.body
    } else if weChangedBody {
        result.body = local.body
    }

    // Edit vs delete: keep the note. A lost edit is worse than an extra note.
    result.isDeleted = local.isDeleted && server.isDeleted
    return result
}

Walk through an example in the interview:

base:    title "Shopping"     body "milk"
iPhone:  title "Groceries"    body "milk"              (changed title)
iPad:    title "Shopping"     body "milk, eggs"        (changed body)
merged:  title "Groceries"    body "milk, eggs"        (both kept, no data lost)

Never trust device clocks for "newest". A phone can be set to 2019. Use the server version.

The local == server check matters too. Example: a push succeeds, but the reply is lost. The next push gets a "conflict" with the same content. It is not a real conflict.

4. Deletes and tombstones

If you remove the row on delete, the sync engine has nothing to push. Other devices keep the note forever. So a delete is just an edit: isDeleted = true.

  • The UI hides notes where isDeleted is true.
  • The server keeps tombstones for a while (for example 30 days), then removes them.
  • A device that was offline longer than that gets "cursor too old" from the server. It must do a full resync: download everything again, then push its dirty notes.

5. Edge cases that show seniority

  • First sync of a big account. Pull in pages of 200. Show the notes as pages arrive. Save the cursor after each page.
  • Logout. Push dirty notes first, or warn: "3 notes are not synced yet." Then delete the local database.
  • Schema changes. Version the local DB and migrate on launch. Old app versions must not delete fields they do not know. The server should keep unknown fields.
  • Background sync. BGAppRefreshTask gives you a short time. Push first: local edits matter most.
  • Apple-only app? CloudKit with CKSyncEngine (iOS 17+) or NSPersistentCloudKitContainer does much of this for you. The cost is less control over conflicts and no Android or web.

Trade-offs

DecisionOption AOption BUsual pick
Source of truthServer, cache on deviceLocal DB, server shares changesLocal DB (offline-first)
IdsServer makes idsDevice makes UUIDsUUIDs on device
Change trackingisDirty flagRevision countersRevisions (safe with edits during sync)
ConflictsLast write winsField merge + keep bothField merge + keep both
Order of "newest"Device clockServer versionServer version
DeletesRemove rowTombstoneTombstone + cleanup later
BackendOwn sync serverCloudKitOwn server if cross-platform

What interviewers look for

  • Local DB as the single source of truth. The UI never waits for the network.
  • Client-made UUIDs, so offline creates do not clash.
  • A clear push/pull protocol with versions and a cursor.
  • Optimistic concurrency on the server (baseVersion check).
  • A real conflict strategy, with an example. You know what LWW loses.
  • Tombstones for deletes, and what happens to a device offline for months.
  • Handling edits during a sync (revision counters), and saving the cursor in the same transaction.
  • Sync triggers that save battery: debounce, network-back, push, background task.
  • Follow-ups: attachments (upload separately, sync a reference), sharing with other users, end-to-end encryption, and CRDTs for real-time collaboration.