System Design

Notes App

easyFreeAsked at
Netflix
By Rotem Meidan ·

"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.

Phase 01

Requirements

Clarifying Questions

QuestionAnswer
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.
Phase 02

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 opId
Note
Invariants - the note id 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.

Phase 03

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.

Note
WorkManager - Android's deferred-work scheduler. Hand it a job and the conditions it needs (here, a live network), and it owns the rest: it waits for the constraint, runs the job, retries with backoff on failure, and keeps the queue intact across a process kill or a reboot. That durability is precisely what draining a sync outbox depends on; the background work and scheduling concept covers the primitive, and the sync dive below shows how we drive it.

Follow a handful of ordinary moments and the pieces line up:

  1. The user types a note. NoteEditorScreen calls SaveNote, which in one Room transaction upserts the notes row and inserts a pending_ops UPSERT row. The list, observing a Room Flow, updates immediately - the note is saved and visible with no network involved. SaveNote then enqueues the unique sync work, but the user's experience is already complete.

  2. 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.

  3. The network returns. WorkManager's constraint is satisfied, so it wakes SyncWorker, which reads every pending_ops row, 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.

  4. A delete. DeleteNote sets isDeleted = true and 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.

  5. A fresh device signs in (a restore). The local database is empty, so SyncNotes runs a full pull - GET /v1/notes?since=0 - and fills the notes table page by page. The list renders as pages land. It is the everyday delta path with the cursor wound back to since=0.

Phase 04

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.

Scenario
The user types a to-do on a subway with no signal. The note has to be saved and on screen instantly, and still there when they resurface. Where does a write go, and what does the UI render from?
BadWrite straight to the server on each edit

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.

GoodWrite to Room first, render from Room

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.

Kotlin - the local write is the whole save
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.

GreatRoom as source of truth + a reactive Flow

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.

Kotlin - the list observes the table
@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.

Note
Local-first inverts the usual order - the database is written first and the network drains from it, instead of the network being written first and the database caching the result. Every other decision on this page follows from that one.
Scenario
The user edits ten notes offline, then locks the phone. The edits are saved in Room, but the cloud knows nothing, and the OS may reap the backgrounded process at any moment. How do the edits reach the server on their own, exactly once, without the user reopening the app?
BadPush from a background Thread

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.

GoodAn outbox row drained by unique WorkManager work

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.

Kotlin - constrained, deduplicated sync work
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.

GreatDedup by opId so a lost ack doesn't double-apply

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.

Kotlin - drain the outbox, delete only on ack
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.

Note
The opId makes retry free - because the server dedups on it, the client can re-send the whole outbox after any crash without fear of double-applying, which is what lets the worker be dumb and the row be the truth. The wire itself is at-least-once; exactly-once lives at the application layer, keyed by opId.
Scenario
Same note, two devices, both offline: one edit on the phone, a different edit on the tablet. They reconnect and both push, and the server is left holding two versions of one noteId with different text. Which wins, and what becomes of the other?
BadLast writer clobbers, no rule

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.

GoodLast-write-wins on a server-assigned updatedAt

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.

GreatKeep the loser as a conflict copy

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.

Note
One shared rule first, then a choice about the loser - server-clock last-write-wins settles on the same winner for every device, and the only decision left is a product one: drop the losing edit, or keep it as a copy the user reconciles by hand.
Scenario
A note gets deleted on the phone, and the delete syncs. Days later a second device runs a full pull, and the note is back in the list. What does it take for a delete to actually stick across sync?
BadHard-delete the local row

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."

GoodSoft-delete with a synced isDeleted flag

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.

GreatTombstone with a TTL, garbage-collected after ack

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.

Note
A distributed delete is a fact every replica has to receive - the tombstone is how that fact travels, and it can only be collected once the ack confirms every device has it and the TTL margin has passed.

Tradeoffs

DecisionChosenAlternativeWhy
Write pathSave to Room first, render from RoomWrite to the server on each editThe 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 mechanismpending_ops outbox drained by WorkManagerA raw Thread that pushes the editThe 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 resolutionServer-timestamp last-write-winsConflict 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.
DeleteTombstone with a TTLHard-delete the local rowA 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.

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 UiState the 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 since cursor (Deep Dive 2).

Done reading? Mark it so it sticks in your dashboard.

Next in System DesignFeature Flag SDK

Discussion (0)

Sign in to join the discussion.
No comments yet. Start the discussion.