ESC

Type to search the knowledge base.

Design Offline-first Notes App

Frontend system design for offline-first notes — local DB, sync queue, conflict UX, PWA shell, and search.

advanced4 min read
  • system-design
  • interview
  • architecture
  • offline

Scope the problem

In scope: notes list + editor that works offline, local persistence, background sync, conflict handling UX, multi-device eventual consistency.

Out of scope: CRDT research paper deep dive (high-level OK), full E2E encryption key management unless asked.

Requirements

Type Examples
Functional CRUD notes offline; sync when online; search local
Non-functional never lose typed data; fast open < 100ms from disk
Product conflict rate, sync lag, data loss incidents = 0

Architecture

┌─────────────────────────────────────────────────────┐
│ UI: NotesList · Editor · SyncStatus                 │
├─────────────────────────────────────────────────────┤
│ Domain: NoteRepository (async API)                  │
├──────────────────┬──────────────────────────────────┤
│ Local DB         │ Sync Engine                      │
│ (IndexedDB)      │ outbox · pull · conflict         │
├──────────────────┴──────────────────────────────────┤
│ Network API · Service Worker (shell + optional bg)  │
└─────────────────────────────────────────────────────┘

Write path always hits local first, then enqueues sync. UI never blocks typing on network.

Data model

type NoteId = string;

type Note = {
  id: NoteId;
  title: string;
  body: string; // markdown or rich doc JSON
  updatedAt: number; // client wall clock — prefer logical time too
  revision: number; // or vector clock / lamport
  deleted: boolean;
  dirty: boolean; // needs push
};

type OutboxItem = {
  id: string;
  noteId: NoteId;
  type: "upsert" | "delete";
  payload: Note;
  attempts: number;
  nextAttemptAt: number;
};

Storage: IndexedDB via idb wrapper; optional OPFS for large attachments.

Sync protocol (sketch)

  1. Push outbox in order (or per-note latest) with idempotency keys
  2. Pull changes since serverCursor
  3. Apply pull through merge function
  4. Persist new cursor
type PullResponse = {
  changes: Note[];
  cursor: string;
};

Auth: session cookies or token; queue pauses on 401 until re-auth.

Conflict UX

Strategy When UX
Last-write-wins simple notes rare silent
Field merge title vs body automatic
CRDT (Yjs) realtime collab complex
Manual divergent body show both + picker

For interview: LWW with revision/updatedAt + manual surface when both dirty and server changed. Banner: “This note changed on another device” with Keep local / Keep server / Compare.

Editor integration

  • Autosave local every N ms / on blur
  • beforeunload flush
  • Debounce outbox enqueue to coalesce keystrokes into one upsert
  • Don’t reset cursor on sync applying same note — compare revisions

Offline indicators

Use offline status patterns: badge “Offline · changes saved on device”, sync spinner when draining outbox, error if outbox failing (quota, 413).

  • Client-side index (FlexSearch/MiniSearch) rebuilt from IDB
  • Or SQL.js / IndexedDB full scan for small datasets
  • Server search only when online for multi-user shared notebooks

PWA

  • Web app manifest + install
  • SW precache shell; never precache all note bodies
  • Background Sync / periodic sync where supported for outbox flush

Performance

  • Lazy-load editor bundle
  • Virtualize notes list
  • Pagination of pull changes
  • Attachment upload separate channel with resume

Security & privacy

  • Device loss: optional passcode / E2E (hard)
  • XSS in note HTML render — sanitize markdown HTML
  • Multi-tab: BroadcastChannel lock so two tabs don’t dual-drain outbox corruptly (leader election)

Tradeoffs

  1. CRDT vs LWW — collab power vs complexity
  2. Local-only first vs online-primary with cache
  3. Single leader tab vs multi-tab merge
  4. Plain text vs rich CRDT doc

Interview close

Local DB is source of truth for UI; outbox + pull sync; coalesce edits; conflict UX; multi-tab leadership; PWA shell. Measure zero data loss and sync lag.

Attachment handling

Large images/PDFs: store blobs in IDB/OPFS, upload via the large-file multipart path when online, and keep notes pointing at local blob ids until remote ids return. Don’t block text editing on attachment sync.

Encryption sketch (if asked)

  • Optional passphrase → key in WebCrypto
  • Encrypt body at rest in IDB
  • Sync ciphertext; server can’t read (true E2E)
  • Tradeoff: server-side search dies; client index only

Conflict UI wireframe

┌─────────────────────────────────────┐
│ Note changed elsewhere              │
│ [Keep mine] [Keep theirs] [Compare] │
└─────────────────────────────────────┘

Compare opens split markdown view of both bodies.

Success metrics

  • Crash-safe drafts (kill browser mid-keystroke → data remains)
  • Sync lag p95
  • Conflict rate and resolution time

Further reading