System Design
Notes App
"Design a local-first notes app like Google Keep."
Analyzing the Problem
Google Keep is a notes and to-do app: a list of short text notes and checklists, an editor for each one, and the whole thing works offline. It is local-first - a note saves to the device the moment you type it, and the cloud is a background sync, not something the editor waits on.
Requirements
Clarifying Questions
| Question | Answer |
|---|---|
| Does the app have to work offline, or is it online-only? | Fully offline - create, edit, and delete with no network, and sync when it returns. |
| Is this single-user across their devices, or shared/collaborative notes? | Single user, multiple devices. No real-time collaboration or sharing. |
| How rich are the notes - plain text, or formatting and attachments? | Short text notes and checklists. No rich text, images, or attachments. |
| How big is a realistic library? | A few thousand short notes, a few KB each - the whole thing is single-digit megabytes. |
Functional Requirements
- Create, edit, and delete a note, and see the change immediately.
- Do all of that fully offline; nothing is lost when there's no network.
- Edits sync to the cloud in the background once the network is available.
- On a fresh device, all of the user's notes appear after sign-in.
- Real-time collaboration and sharing.
- Rich text, images, and attachments.
Non-Functional Requirements
- Write latencySaving a note never waits on the network; the edit is on screen instantly.
- DurabilityAn edit made offline survives app kill and reboot, and syncs later.
- Exactly-once applicationA retried edit applies once on the server, never twice.
- Conflict handlingAn edit to the same note on two devices resolves to a defined rule, not a silent random winner.
- Delete correctnessA deleted note stays deleted and doesn't resurrect on the next sync.
Data Model + API
Entities and the wire
Before any entity, the one platform choice the whole design rests on: Room, Android's SQLite wrapper. Room here is the source of truth: a user's edit is authoritative the instant it lands in the table, and everything the network does happens downstream of that write. And because Room can expose that table as an observable Flow, the write redraws the list on its own, with no refresh call in between.
Two tables carry the design. The first is the note itself, a plain row keyed by a client-generated id.
@Entity(tableName = "notes")
data class Note(
@PrimaryKey val id: String, // client-generated UUID - stable before the server ever sees it
val title: String,
val body: String,
val updatedAt: Long, // last-edit time; the conflict tiebreaker
val isDeleted: Boolean = false, // soft-delete tombstone - see the delete deep dive
)The second is the durable sync intent, an outbox row. It is written the moment a note is created, edited, or deleted, and removed only when the server acks that op. What matters is that the pending work lives as a row on disk, not in the memory of whatever sync code happens to be running at the time.
@Entity(tableName = "pending_ops")
data class PendingOp(
@PrimaryKey val opId: String, // client-generated - the dedup key for exactly-once sync
val noteId: String,
val type: OpType, // UPSERT | DELETE
val updatedAt: Long,
)The wire is the simplest thing that covers the four requirements: pull the library, and push a batch of ops. Payloads are tiny - a note is a few KB of text, not a blob - so there is no upload session, no signed URL, none of the machinery a media app needs.
GET /v1/notes?since={serverTime}
-> { notes: [Note], serverTime } # full pull when since=0, a delta (only what changed since the last pull) otherwise
POST /v1/notes/sync
body: { ops: [{ opId, noteId, type, title, body, updatedAt }] }
-> 200 { acked: [opId], conflicts: [{ noteId, serverNote }] } # idempotent on opIdid and the opId are both client-generated, so a note has a stable identity before the server ever sees it and a retried push dedups on opId. The server stamps the authoritative updatedAt on receipt; the client's value is a hint. The client also persists the serverTime each pull returns and sends it back as the next since, so an incremental pull asks only for what changed. isDeleted is a real column, not a deletion - a deleted note stays in the table as a tombstone until the server acks it.Why two tables, not one dirty flag
A tempting shortcut is a single isDirty boolean on the note row instead of a separate outbox. It collapses the moment a note is edited twice before the first sync lands, or edited and then deleted - one flag can't represent two pending ops, and it can't carry a stable opId for dedup. The separate pending_ops table makes the sync intent a first-class, ordered, individually-ackable record. That is the outbox pattern, and it is the seam between user intent and the network for the rest of the page.
High-Level Design
Everything in this design bends around one guarantee: the edit is durable and on screen before the network is ever consulted. So SaveNote writes the note and its outbox row to Room in one transaction, and the list renders straight from a Room Flow - the edit appears with no round-trip. The push to the cloud happens later, in the background, off that durable outbox. The rest of the cast - a NotesViewModel, one NotesRepository, a WorkManager-driven SyncWorker - is on the diagram and in the walkthrough just below. What this draft still gets wrong under pressure is the work of the deep dives: a retried op applied twice, two devices editing one note, and a delete that crawls back.
Follow a handful of ordinary moments and the pieces line up:
-
The user types a note.
NoteEditorScreencallsSaveNote, which in one Room transaction upserts thenotesrow and inserts apending_opsUPSERT row. The list, observing a RoomFlow, updates immediately - the note is saved and visible with no network involved.SaveNotethen enqueues the unique sync work, but the user's experience is already complete. -
Offline, and staying that way. Nothing about step 1 changes. The Room write succeeds, the outbox row sits and waits, and WorkManager holds the job because its network constraint isn't met. The user keeps creating and editing; each edit is another outbox row.
-
The network returns. WorkManager's constraint is satisfied, so it wakes
SyncWorker, which reads everypending_opsrow,POSTs them to/v1/notes/sync, and on each ack deletes the corresponding outbox row. The whole drain runs with no spinner and no prompt; the user never sees it. -
A delete.
DeleteNotesetsisDeleted = trueand writes a DELETE outbox row - it does not remove the row. The list filters out tombstoned notes so it disappears from view, but the row survives so the next pull can't resurrect it. The delete deep dive covers why. -
A fresh device signs in (a restore). The local database is empty, so
SyncNotesruns a full pull -GET /v1/notes?since=0- and fills thenotestable page by page. The list renders as pages land. It is the everyday delta path with the cursor wound back tosince=0.
Deep Dives
Four pressures separate that draft from something shippable, and the dives take them roughly in the order a user would feel them. First the write path that has to feel instant, then the background sync that has to survive a process kill. After those come the two correctness traps every sync design eventually hits: one note edited on two devices at once, and a delete that refuses to stay dead.
The instinct from a web background: the editor POSTs the note to the API and shows a spinner until the 200. It demos fine on Wi-Fi and is wrong on a phone. Every keystroke (or every debounced save) now blocks on a round-trip, so the editor stutters under latency; and offline there is no 200 at all, so the write either fails or is dropped. The user's own text - the one thing the app exists to keep - is held hostage by the network.
Flip the order. SaveNote writes the note to the local notes table and returns; the network is not in this path at all. The editor never waits on anything but a local SQLite insert, which completes in microseconds, so the save is instant online or off.
suspend fun saveNote(note: Note) {
notesDao.upsert(note.copy(updatedAt = System.currentTimeMillis()))
// the user's part is done here; sync is enqueued separately, below
}The note is now durable and on screen. What's missing is reactivity: a plain insert doesn't tell the list it changed, so we'd be back to manual refreshes.
Make the list a function of the database, not of the save call. Room can expose a query as an observable Flow, so the list screen subscribes once and Room re-emits the whole list on every write - the editor's job ends at the insert, and the list updates itself.
@Query("SELECT * FROM notes WHERE isDeleted = 0 ORDER BY updatedAt DESC")
fun observeNotes(): Flow<List<Note>>Now the architecture is honest about what's authoritative: the user's edits live in Room and the screen just draws whatever Room currently holds, so the write path has zero network in it. The network becomes a separate, downstream concern - getting what's already saved up to the cloud - which is the next dive. This is the state management shape the rest of the app inherits: one source of truth, the UI derived from it.
thread { pushPendingNotes() } survives only as long as the app stays in the foreground. Background it and the process turns killable at any instant; a kill mid-request takes the in-flight push down with it, and nothing on disk remembers the attempt - so the next launch can't tell which edits landed and which evaporated. Even an edit that reached the server cleanly, only to lose its ack to a dropped connection, comes back looking un-synced with no way to know it already applied.
The fix is already in Phase 2: the sync intent is a pending_ops row on disk before any socket opens, so it can't die with the process. Sync then reduces to a routine - enqueue one unique WorkManager job, and when it fires, read the outbox and push.
val work = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(
Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED) // no point draining with no network
.build())
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(ctx)
.enqueueUniqueWork("sync", ExistingWorkPolicy.KEEP, work)WorkManager survives process death and reboot, holds the job until the network is connected, retries with backoff, and the unique name collapses ten rapid edits into one drain pass instead of ten workers. The outbox rows outlive any kill, so the work always resumes. But resuming is what exposes the next hazard: if a push reached the server and only its ack was lost, the next run re-sends an op the server has already applied.
One failure mode remains, and it's the subtle one: the worker pushes an op, the server commits it, then the 200 is lost to a dropped connection. The outbox row is still there (we only delete on ack), so the next run re-sends it. Without protection the server now applies the same edit twice.
The fix is the client-generated opId on every outbox row. The server records which opIds it has applied and treats a repeat as a no-op, returning the same ack. So the push is safe to retry any number of times.
override suspend fun doWork(): Result {
val ops = pendingDao.all()
if (ops.isEmpty()) return Result.success()
val acked = try { api.sync(ops.toRequest()).acked } catch (e: IOException) { return Result.retry() }
pendingDao.deleteByOpIds(acked) // re-sent ops the server already had come back acked too
return if (pendingDao.count() == 0) Result.success() else Result.retry()
}Sync is now durable and idempotent. That one outbox row is doing the work: it exists before the first byte leaves and disappears only after the ack, so an interruption anywhere resolves by re-reading rows instead of trusting that the last run finished. The pattern generalizes - any mobile client pushing local writes upstream wants an at-least-once wire under an idempotent server - but here it carries nothing heavier than text deltas.
noteId with different text. Which wins, and what becomes of the other?Whatever push lands second overwrites the row, end of story. There is no rule - the winner is just whichever device happened to reconnect last, which has nothing to do with which edit the user actually wants. The losing edit vanishes with no trace, and because the order is accidental, the same two edits could resolve differently on a retry. This isn't a policy; it's the absence of one.
Make the rule explicit and deterministic: the version with the newer updatedAt wins, and the server assigns that timestamp on receipt. The server clock matters here - device clocks drift, and if you trusted the client's updatedAt, a phone whose clock runs five minutes fast would beat a genuinely newer edit from a correct one. With a single authoritative clock, "newer wins" is consistent for everyone.
When the loser's push arrives, the server replies with the winning serverNote in the conflicts array; the client overwrites its local copy. The result is one rule, trivial to reason about, and exactly right for a single-user app where two-device concurrent edits are rare. The honest cost: the losing edit is still dropped, just deterministically now instead of randomly.
For the rare case where dropping the loser is unacceptable, don't make the system guess - surface both. When the server detects a conflict, the client keeps the winning version under the original noteId and writes the losing text as a new note titled "(conflicted copy)". The user sees both side by side and merges by hand.
This is deliberately the simplest thing that loses no data: no per-field merge, no operational transform, no CRDT (a conflict-free replicated data type - the structure a collaborative editor uses to merge concurrent edits without a central authority) - those belong in a collaborative doc editor, not a single-user notes app. You buy that safety with a little clutter, an extra note to reconcile. For most notes a rare dropped edit is survivable and last-write-wins is enough; the conflict copy is worth its clutter only when the text is too precious to lose.
The first cut is a literal delete: DELETE FROM notes WHERE id = :id. On-device it works. But the next sync pulls the library from the server, the server still has the note (nobody told it to delete), so the pull re-inserts it - the delete resurrects. Even after the delete reaches the server, a second device that deleted nothing pulls a list that, for a window, still contains the note. A removed row carries no information; a pull can't tell "deleted" from "never existed."
Keep the row, mark it. DeleteNote sets isDeleted = true and enqueues a DELETE op; the list query already filters WHERE isDeleted = 0, so it vanishes from view while the row remains. The DELETE op syncs like any other, and the server marks its copy deleted too. Now a pull returns the note with isDeleted = true, so every device learns the deletion instead of resurrecting the note. Correct, but those marked rows never leave on their own, and a heavy user's table steadily fills with them.
Soft-delete is correct but isDeleted rows accumulate forever - a user who creates and deletes thousands of to-dos grows a table of dead markers that every pull and every query pays for. The fix is to treat the deleted row as a tombstone with a lifetime: keep it long enough to guarantee every device has synced the deletion, then purge it.
Concretely, once the server acks the DELETE op and a TTL window passes (say 30 days, comfortably longer than any device stays offline), a local cleanup pass hard-deletes the tombstone, and the server does the same on its side. The TTL is the safety margin: purge too early and a device that was offline past the window pulls a list missing the note and never learns it was deleted, resurrecting it again. Keep it bounded and the table stays small without risking a zombie.
Tradeoffs
| Decision | Chosen | Alternative | Why |
|---|---|---|---|
| Write path | Save to Room first, render from Room | Write to the server on each edit | The UI never waits on the network and offline edits survive; the cost is the note isn't in the cloud until the outbox drains. The server-first path only wins if a write must be server-confirmed before the user proceeds, which a notes app never requires. |
| Sync mechanism | pending_ops outbox drained by WorkManager | A raw Thread that pushes the edit | The thread dies on backgrounding and loses the edit; the outbox row survives process death and retries. A Thread is only acceptable for work that may safely vanish, which a user's note is not. |
| Conflict resolution | Server-timestamp last-write-wins | Conflict copy (keep both) | LWW is one trivial rule, right when concurrent edits are rare and a dropped loser is tolerable. The conflict copy wins when the user's text is precious enough that silently losing it is unacceptable, at the cost of clutter. |
| Delete | Tombstone with a TTL | Hard-delete the local row | A hard delete resurrects on the next pull because a removed row carries no information. The tombstone propagates the deletion; the TTL keeps dead markers from accumulating forever. |
Related core concepts
The mechanisms on this page are each developed in depth in a core concept:
- Persistence - Room as the source of truth and the outbox, and observing tables as
Flows (Deep Dives 1 and 2). - Offline-First and Sync - the outbox pattern, delta sync, conflict resolution, and tombstone deletes (Deep Dives 2, 3, and 4).
- Background Work and Scheduling - WorkManager constraints, retry with backoff, and unique work for the sync drain (Deep Dive 2).
- State Management - the discriminated
UiStatethe list renders from a shared ViewModel observing Room (Deep Dive 1). - Network Protocols - idempotent push keyed by a client opId and delta pull with a
sincecursor (Deep Dive 2).
Done reading? Mark it so it sticks in your dashboard.