Design an offline-first note-taking app (think: a mobile Notion/Evernote)
Walk through an architecture where the local database is the source of truth and the network is just another writer — sync, don't block.
LEARN BY DOINGPut your intuition to work3 steps
Design an offline-first note-taking app (think: a mobile Notion/Evernote)
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Clarifying the requirements
Before drawing a single box, I'd pin down what "offline-first" actually has to mean for this product, because the phrase is used loosely and it changes the architecture. A note-taking app like Notion or Evernote needs: instant local reads and writes (typing must never wait on a network round trip), notes organized into a hierarchy (notebooks, pages, nested blocks), rich content (text, checklists, images, maybe embedded tables), multi-device sync for the same user, and — the hard part — the ability for two devices to edit the same note while both were offline, then reconcile without silently destroying one side's work. I'd also ask about scale (is this a personal tool or does it need shared/collaborative notebooks with other users?) and about consistency expectations — is "eventually consistent, last-writer-wins is acceptable for 95% of edits, but we need to never lose data outright" a fair bar? For this answer I'll assume: single-user editing is the primary case, shared notebooks are a secondary feature, and data loss is unacceptable even under conflicting concurrent edits.
#The core architectural bet
The single most important decision is this: the local database is the source of truth for the UI, always — not the network. Every screen reads from and writes to the local store, full stop. The network layer is demoted from "the thing that owns data" to "a background process that moves rows in and out of the local store." This inversion is what makes the app feel instant (no spinner ever gates a keystroke) and what makes offline just... the normal case, not a special mode the app has to detect and branch on. Concretely, that means the UI layer is built on a reactive local database — Room with Flow on Android, Core Data with NSFetchedResultsController/Combine on iOS, or a cross-platform embedded db like SQLite via SQLDelight or Realm — and the UI observes queries against that database. A keystroke writes to the local DB in a transaction, the reactive query re-emits, the UI re-renders. The sync engine is just another client of that same database: it reads local changes to push, and writes remote changes it pulls, through the exact same write path the UI uses (so conflict resolution and change-tracking triggers fire consistently regardless of the writer).
#Local storage and the change log
Each note is stored as a row (or, if I want fine-grained conflict resolution, as a tree of blocks — one row per paragraph/checklist item/heading, which is exactly how Notion actually models content). Alongside the content tables, I keep an append-only operation log: every local mutation is recorded as a small, structured op — {opId, entityId, field, value, timestamp, deviceId, vectorClock} — before or atomically with the mutation itself. This log is the unit of sync: instead of diffing whole documents, the sync engine ships a stream of operations, which is both bandwidth-efficient and gives us the raw material for conflict resolution. I'd version each note (or each block, for finer granularity) with either a vector clock (per-device logical counters) or, more pragmatically, a hybrid logical clock — a timestamp that's monotonic per device and comparable across devices without needing full NTP-grade synchronization. The vector clock is what lets us actually detect a conflict (two edits to the same entity where neither op's clock dominates the other) rather than just guessing based on wall-clock time, which drifts and lies.
#Sync engine and conflict resolution
The sync engine runs as a background worker (WorkManager on Android, a BGProcessingTask on iOS) with three responsibilities: push local ops the server hasn't seen, pull remote ops since the last cursor, and reconcile overlapping ops into a single, deterministic result. For push, batching matters — don't fire a network call per keystroke; debounce local ops (e.g., flush the queue 1–2 seconds after typing pauses, or immediately on app backgrounding) so a paragraph of typing becomes one network request, not two hundred. For conflict resolution, I'd reach for a strategy proportional to the actual field type: for scalar fields (a note's title, a checkbox's checked state) last-writer-wins by hybrid logical clock is simple and good enough — the UX cost of occasionally losing the "loser" edit on a single boolean is low. For the note body — actual prose — last-writer-wins is unacceptable, because it silently deletes a paragraph someone wrote while offline. There I'd model the content as a CRDT (a sequence CRDT like RGA or the tree-based approach used by real collaborative editors), which merges concurrent insertions/deletions deterministically without a central authority resolving anything — both devices converge to the same result independently. This is genuinely more complex to implement, so I'd scope it explicitly: CRDTs for the body text and block tree, simple LWW-by-field for everything else (metadata, tags, pinned state).
#Handling the failure modes
A few edge cases decide whether this design survives contact with reality. First, partial connectivity — a device on a flaky connection that half-completes a sync — is handled by making the push protocol idempotent (each op carries a client-generated UUID, so replaying it after a dropped response is a no-op on the server) and by never marking local ops as "synced" until the server acknowledges them explicitly, not just on request completion. Second, large media (images, attachments) shouldn't ride in the same operation log as text ops; they're uploaded separately to blob storage with the note holding just a reference and a local cache path, so a slow image upload never blocks text sync. Third, tombstones: deleting a note offline has to propagate as a delete operation, not a silent absence, or a device that resurrects from an old sync cursor will "un-delete" it. Finally, I'd surface sync state honestly in the UI — a subtle indicator per note (synced / syncing / offline-pending) — because users of tools like this have been burned before by silent data loss and trust is earned by transparency, not by hiding the sync mechanics entirely.
#Wrapping up the tradeoffs
If I were asked to defend the biggest cost of this design, it's engineering complexity: CRDTs and vector clocks are a real investment compared to "call a REST API and cache the response," and I'd only justify that investment because true offline multi-device editing is a headline feature, not an afterthought. For a simpler product where offline just means "don't crash on a subway," I'd happily downgrade to a much lighter local-cache-plus-retry-queue design. The other cost is storage growth from an append-only op log — I'd need a periodic compaction job that snapshots resolved state and prunes acknowledged ops older than some retention window, so the log doesn't grow unbounded on a device that's been offline for a month. Given the requirements we clarified up front — real offline editing, real conflict safety, multi-device sync — I'd stand behind local-DB-as-truth plus an op-log-driven sync engine with per-field-appropriate conflict resolution as the right shape, and treat the CRDT investment for body content as the one place worth the extra complexity.