Design Offline-first Notes App
Frontend system design for offline-first notes — local DB, sync queue, conflict UX, PWA shell, and search.
- 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)
- Push outbox in order (or per-note latest) with idempotency keys
- Pull changes since
serverCursor - Apply pull through merge function
- 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
beforeunloadflush- 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).
Search
- 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:
BroadcastChannellock so two tabs don’t dual-drain outbox corruptly (leader election)
Tradeoffs
- CRDT vs LWW — collab power vs complexity
- Local-only first vs online-primary with cache
- Single leader tab vs multi-tab merge
- 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
Related on this site
- Offline Status Indicator
- PWA Install and Offline Shell
- Realtime WebSocket UI Design
- System Design Interview Framework