Notes — Engineering RFC
RFC-0142
Offline-First Sync for Notes
Make the on-device database the source of truth and synchronize opportunistically, so every note stays readable and writable with or without a network connection.
1Summary
Notes today requires connectivity to read or write. We propose a local-first architecture: the device database is authoritative, a background engine reconciles with the server, and E2EE is preserved. Ships behind a kill-switch flag.
2Motivation
Offline or degraded-network sessions account for 31% of mobile opens but produce 72% of “lost note” support reports (Q2 review). A failed save retries in memory; force-quitting discards the edit. Offline access is the most-cited complaint in store reviews.
3Goals & Non-Goals
✓ Goals
- Reads and writes never block on the network.
- Multi-device edits merge unattended in ≥99% of cases.
- E2EE preserved — the server stores ciphertext ops only.
- Sync lag <5s p50; durable across crashes.
✕ Non-goals
- Real-time co-editing and presence (v2 collaboration).
- A version-history UI (the log exists; the UI follows).
- Attachment blob pipeline changes (>25MB objects).
- Shared-note permission model changes.
4Design
The client treats the local store as the system of record. Every mutation commits to SQLite and appends an operation to a durable outbox in the same transaction, so an acknowledged edit can never be lost to a crash or a dead radio.
4.1Local store & data model
SQLite on iOS, Android, and desktop; wa-sqlite over IndexedDB on web. A note is a document (note_id = UUIDv7) with scalar fields and a CRDT body:
| Field | Representation | Merge rule |
| body | Sequence CRDT (RGA, tombstoned chars) | Character-level merge, deterministic interleave |
| title · pinned · color | Scalar | Per-field last-writer-wins on HLC |
| sort_key | Fractional index | LWW; rebalanced when neighbors collide |
| deleted_at | Tombstone (30d retention) | Delete wins; edited copy is preserved |
4.2Operation log & ordering
Each mutation emits an op {op_id, note_id, actor_id, hlc, type, payload}. A hybrid logical clock orders ops causally without trusting wall time; ties break on actor_id. Ops are idempotent, so replayed pushes are safe no-ops.
LOCAL STORESQLite · source of truth
same txn
SYNC ENGINEoutbox · push / pull
HTTPS · E2EE
SERVERop log + snapshots
Fig 1 — one direction per half-loop; either side may fail independently
RFC-0142
Offline-First Sync for Notes
4.3Sync loop
- Push — batch outbox ops (≤100 ops or 256KB) to
POST /sync/push; the server appends to the per-user log and returns a durable cursor.
- Pull —
GET /sync/pull?cursor=c returns ops after c; apply in HLC order, checkpoint the cursor.
- Catch-up — a missing or stale cursor (>90d server retention) triggers a snapshot + fresh cursor; the client rebuilds rather than replays.
- Scheduling — run on connectivity change, foregrounding, and every 30s while dirty; exponential backoff capped at 5min. Push and pull are independent half-loops, so a one-way failure never stalls the other.
4.4Conflict handling
- Body text — the RGA sequence CRDT interleaves concurrent inserts deterministically by
(hlc, actor_id); deleted characters are tombstoned and compacted into snapshots.
- Scalars — per-field LWW on HLC, so a title edit on one device and a pin on another merge cleanly instead of clobbering the note.
- Delete vs. edit — delete wins, but the losing revision is kept as a conflict copy in a
Recovered folder for 30 days. Expected rate <0.5% of syncs; alert at >2%.
5Alternatives Considered
| Approach | Why it loses |
| Whole-note LWW | Any concurrent edit silently discards one side — unacceptable for text, the field that matters most. |
| Operational Transform | Requires a central sequencer and transform functions; more machinery than our scale justifies. A CRDT gives the same merge UX with no server coupling. |
| Managed sync (PowerSync, Realm, Firestore) | Faster to ship, but plaintext routing metadata weakens our E2EE posture and per-MAU pricing compounds. Revisit for v2 collaboration. |
| Git-style 3-way merge | Needs per-device ancestor tracking and a manual conflict UI — a poor fit for a consumer notes surface. |
6Edge Cases & Failure Modes
- Clock skew — HLC everywhere; wall time is display-only and never feeds merge logic.
- Crash mid-sync — the outbox is durable before the UI confirms; apply is idempotent, so partial flushes replay harmlessly.
- Tombstone GC vs. long-offline device — devices stale beyond 30d tombstone retention take the snapshot path instead of incremental pull.
- Cursor corruption — cursors carry a checksum; a mismatch triggers an automatic snapshot re-sync rather than a stuck client.
- Same account, many devices — a per-device
actor_id is minted at first sync and rotates on reinstall.
7Migration & Rollout
- Migration — on upgrade, import the user’s existing notes as a baseline snapshot, then start incremental sync; ~200ms for a median library (1.2k notes).
- Phase 1 — flag on for internal + beta cohorts; two weeks of dogfood with conflict-copy review.
- Phase 2 — 5% → 50% → 100% over three weeks. Kill switch restores online-only mode while keeping the local read cache.
- Watch — sync lag p50/p95, conflict-copy rate, outbox depth, op-log growth per user-day, failed-pull rate.
8Open Questions
- Is 90 days of server op-log retention the right balance of storage cost vs. snapshot frequency? Pending infra cost model.
- Do conflict copies belong inline in the note list or in a separate
Recovered folder? Needs design review (DES-0211).
- Does v2 collaboration reuse this op log, or do we adopt a hosted CRDT service at that point?