Design an Offline-First Notes App
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.| Part | Job |
|---|---|
| UI | Reads and writes through the repo. Never calls the network. |
NotesRepo | Saves edits to the DB and marks them "dirty" (changed, not yet synced). |
| Local DB | The source of truth on the device. |
SyncEngine | Sends local changes up (push), gets other devices' changes (pull), merges conflicts. |
| Server | Gives 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:
idis aUUIDmade on the device. Two offline devices can create notes without id clashes.isDeletedkeeps the row after a delete. Without it, other devices never learn about the delete.serverVersiontells the server which version our edit was based on.baselets us see who changed what when there is a conflict.localRevisionandsyncedRevisionhandle 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:
- Push: send all dirty notes with their
baseVersion. - 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
baseandserverVersionfrom 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 timeWith 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:
| Strategy | How | Good | Bad |
|---|---|---|---|
| Last write wins (LWW) | Newest version wins | Very simple | Silently loses an edit |
| Field-level merge | Compare each field with base | Keeps most edits | Needs base |
| Keep both | Make a "conflicted copy" note | Never loses data | User must clean up |
| CRDT | Special data types that always merge | Merges text by character | Complex, 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
isDeletedis 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.
BGAppRefreshTaskgives you a short time. Push first: local edits matter most. - Apple-only app? CloudKit with
CKSyncEngine(iOS 17+) orNSPersistentCloudKitContainerdoes much of this for you. The cost is less control over conflicts and no Android or web.
Trade-offs
| Decision | Option A | Option B | Usual pick |
|---|---|---|---|
| Source of truth | Server, cache on device | Local DB, server shares changes | Local DB (offline-first) |
| Ids | Server makes ids | Device makes UUIDs | UUIDs on device |
| Change tracking | isDirty flag | Revision counters | Revisions (safe with edits during sync) |
| Conflicts | Last write wins | Field merge + keep both | Field merge + keep both |
| Order of "newest" | Device clock | Server version | Server version |
| Deletes | Remove row | Tombstone | Tombstone + cleanup later |
| Backend | Own sync server | CloudKit | Own 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 (
baseVersioncheck). - 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.