Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Solo iOS Growth Diary — Vol. 4: The Outbox (Off...

Solo iOS Growth Diary — Vol. 4: The Outbox (Offline-First Patterns for Memo Apps)

Most memo apps mutate the screen first and hope the write lands. Vol. 4 of Solo iOS Growth Diary argues the opposite: an append-only Outbox beats optimistic UI every time. Persist the user's intent to a durable, local, append-only log before you touch the network or the view — then drain it.

Inside: the Write Durability Score (WDS), the Outbox state machine (pending → sending → sent with a jittered backoff loop), an offline-first architecture in SQLite WAL via GRDB, a drain loop driven by NWPathMonitor + BGTaskScheduler, and UUID idempotency keys that turn at-least-once delivery into an exactly-once effect. From production: 9 ms median enqueue-to-durable, 840 ms median drain-to-sent, 3,112 rows replayed after forced-quits with 0 duplicates, 14 memos lost pre-Outbox vs 0 after — in ~214 lines of Swift.

Anti-thesis for every claim: "Just use CRDTs" (a conflict a single-writer memo app never has), "Optimistic UI is good enough" (an OOM kill erases an in-memory write), and "SQLite is overkill, use a JSON file" (no atomic append, no crash-safe journal). One drain can also fan out to multiple sinks: a successful send reaches the AES-GCM email path and, for Advanced-Mode users, an append to the user's Obsidian daily note on iCloud Drive (iPhone-only; iCloud reflection can lag a few seconds).

Part of the 12-week Solo iOS Growth Diary — new deck every Tuesday 22:00 JST. Keywords: offline first ios, outbox pattern swift, append-only persistence, sqlite wal ios, idempotency key, NWPathMonitor, BGTaskScheduler, GRDB, Captio, dual-sink delivery, Obsidian append.

Follow: speakerdeck.com/simplememo · x.com/simplememofast
References: simplememofast.com/obsidian/ · apps.apple.com/us/app/captio-style-simple-memo/id6758438948?ct=obsidian-en&mt=8

Avatar for SimpleMemo

SimpleMemo

June 02, 2026

More Decks by SimpleMemo

Other Decks in Programming

Transcript

  1. S O L O i O S G R O

    W T H D I A R Y VOL. 04 The Outbox Offline-First Patterns for Memo Apps Why an append-only Outbox beats optimistic UI — every single time. Append-only SQLite WAL Offline-first speakerdeck.com/simplememo · x.com/simplememofast · 2026-06-02
  2. Solo iOS Growth Diary · Vol. 4 · The Outbox

    02 / 35 The 12-week promise 01 Battle-tested patterns From a solo iOS founder shipping in public. 02 Real numbers, no mythology Cost, latency, churn — measured and printed. 03 Anti-thesis every volume Three counter-arguments per topic. Survival > fashion. 04 Reproducible code Copy-paste over inspirational quotes.
  3. Solo iOS Growth Diary · Vol. 4 · The Outbox

    03 / 35 Today's roadmap 1 The Optimistic-UI Trap Slide 04–06 2 How often writes go offline Slide 07–08 3 WDS: the math of "is saved" Slide 09–11 4 The Outbox state machine Slide 12 5 Architecture, drain & dual sink Slide 13–15 6 Matrix & the actual code Slide 16–19 7 Anti-thesis — three counter-punches Slide 20–22 8 Idempotency, cost, weaknesses Slide 23–25 9 7-Step Playbook Slide 26–30 1 0 Honest Scoreboard Slide 31–32
  4. Solo iOS Growth Diary · Vol. 4 · The Outbox

    04 / 35 The network is the unreliable part. Your write shouldn't be. So why do most apps mutate the screen before the bytes are safe?
  5. Solo iOS Growth Diary · Vol. 4 · The Outbox

    05 / 35 A short history of the lost write 2007 iPhone: no offline API 2010 Core Data on iOS 2014 SQLite WAL becomes default 2017 CRDTs go public (Automerge) 2019 Optimistic UI everywhere 2022 Local-first manifesto 2026 Append-only Outbox Amber = durability milestones. Every era re-learns the same rule: persist before you paint.
  6. Solo iOS Growth Diary · Vol. 4 · The Outbox

    06 / 35 The memo that looked saved SimpleMemo v0.9 (pre-Outbox era) Tap Send → the row animates into the list → fire the network call. If the call failed on a flaky connection, we rolled back the row… sometimes. On an out-of-memory kill between paint and persist, the write was simply gone. 14 memos lost (v0.9) 0 errors shown to users 100% of them looked saved 1 root-cause rewrite Every lost memo had shown the user a checkmark. The view said "saved"; the bytes disagreed. Insight → a write that only lives in the view layer is a rumor, not a record.
  7. Solo iOS Growth Diary · Vol. 4 · The Outbox

    07 / 35 How often do writes start offline? Memo capture is impulsive — elevators, subways, planes, dead zones. The network is frequently absent at the exact moment of intent. 1 in 8 memo writes start with no usable network 4.2 % of sessions see at least one send failure 23 s median time to first drain after a dead zone 100 % of these should cost the user zero memos T H E Q U I E T T A X You will never see most offline-loss in your crash logs — the app didn't crash, it just dropped a write. The only defense is to make the write durable before the network is ever consulted. Source: SimpleMemo telemetry, 2026-Q1 (12,400 capture sessions).
  8. Solo iOS Growth Diary · Vol. 4 · The Outbox

    08 / 35 Why optimistic UI breaks It mutates the view before the bytes are durable A crash or OOM kill between paint and persist loses the tail. The user already saw "saved." Failure is silent by default Rollback UX is hard, so most apps just… don't. The memo quietly disappears with no trace. A failed write has nowhere to retry from Without a durable queue, there is no record of the intent to replay when the network returns. It conflates "rendered" with "recorded" Two completely different guarantees wearing the same green checkmark. It gets worse at scale More users → more edge networks → more silent losses you will never reproduce in a debugger.
  9. Solo iOS Growth Diary · Vol. 4 · The Outbox

    09 / 35 “ Append first. Sync later. — Solo iOS Growth Diary · Vol. 4
  10. Solo iOS Growth Diary · Vol. 4 · The Outbox

    10 / 35 Reframe: an Outbox is a journal, not a cache You're not caching a write. You're journaling an intent. The Outbox is a write-ahead log for user actions: durable, ordered, replayable. The screen, the mailbox, and the search index are all projections drained from it. 9 ms median enqueue → durable (WAL append) append- only one immutable row per intent 1 writer per device — no merge required ∞ replays rebuild any projection from the log
  11. Solo iOS Growth Diary · Vol. 4 · The Outbox

    11 / 35 Write Durability Score (WDS) — the math WDS = survives_app_kill + (survives_offline × 2) + (survives_reinstall × 3) − taps_to_confirm Higher is better. The Outbox is the cheapest jump from "looks saved" to "is saved." In-memory + optimistic UI -1 UserDefaults / JSON file 1 Core Data default store 3 Append-only Outbox (SQLite WAL) 5 Outbox + canonical re-sync 6 0
  12. Solo iOS Growth Diary · Vol. 4 · The Outbox

    12 / 35 The Outbox state machine pending row appended, not yet drained sending drain in flight, idempotency key set sent delivered to all sinks, row now immutable ▶ ▶ failed network/sink error → backoff, then retry ▼ ▶ backoff + jitter I n v a r i a n t s • Rows immutable once sent • status is the only mutable field • the table only ever grows
  13. Solo iOS Growth Diary · Vol. 4 · The Outbox

    13 / 35 Architecture · append the intent, drain in the background UI tap enqueue(memo) before any paint ▶ Outbox (WAL) outbox.sqlite append-only ▶ Drain worker BGTaskScheduler + NWPathMonitor ▶ Sinks email · vault · FTS index S C H E M A CREATE TABLE outbox ( rowid INTEGER PRIMARY KEY AUTOINCREMENT, -- append order idempotency_key TEXT NOT NULL UNIQUE, -- UUID; sinks dedupe on it payload BLOB NOT NULL, -- the memo (AES-GCM at rest) status TEXT NOT NULL DEFAULT 'pending', -- pending|sending|sent|failed attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at REAL, -- backoff schedule created_at REAL NOT NULL ); PRAGMA journal_mode = WAL; -- atomic, crash-safe, concurrent read during write
  14. Solo iOS Growth Diary · Vol. 4 · The Outbox

    14 / 35 The drain loop · backoff with jitter Drain when the network returns (NWPathMonitor), not on a timer. Each failed attempt waits exponentially longer — capped, and jittered so a fleet of devices never stampedes a recovering server. attempt 1 1 s attempt 2 2 s attempt 3 4 s attempt 4 8 s attempt 5 16 s attempt 6 32 s attempt 7+ 5 min (cap) delay = min(base · 2ⁿ, 300 s) ± 20% jitter · base = 1 s 2ⁿ exponential ±20% jitter 300 s cap ≥1 at-least-once Source: SimpleMemo drain telemetry, 2026-Q2.
  15. Solo iOS Growth Diary · Vol. 4 · The Outbox

    15 / 35 One drain, two sinks A successful drain doesn't go to one place — it fans out to every sink the user opted into. Outbox row one durable intent + UUID key drain() fan-out, idempotent ▶ Sink A · Email (AES-GCM) SMTP send, unchanged path from Vol. 3. Encryption boundary stops at the network edge. Sink B · Obsidian daily note Appends a `- HH:mm memo` line to today's `yyyy-MM-dd.md` on iCloud Drive. No plugin. ▶ ▶ F A I R P R I N T Advanced-Mode, opt-in. iPhone-only today; the vault must live on iCloud Drive or "on this iPhone"; the file write is instant but iCloud reflection can lag a few seconds. The append is a local, on-device operation — it never crosses the email encryption boundary. Ref: simplememofast.com/obsidian/
  16. Solo iOS Growth Diary · Vol. 4 · The Outbox

    16 / 35 Optimistic UI vs Outbox vs full CRDT Property Optimistic UI Append-only Outbox Full CRDT (Yjs) Source of truth The view (!) Local append log Merged doc state Survives app kill No Yes (WAL) Yes Offline writes Lossy Durable + queued Durable Conflict model None None needed (1 writer) Automatic merge Approx. lines of code ~20 ~214 thousands + library Right for Throwaway demos Single-user memo apps Multi-cursor docs The Outbox column is the sweet spot for a single-writer memo app: CRDT durability without CRDT cost.
  17. Solo iOS Growth Diary · Vol. 4 · The Outbox

    17 / 35 Code · enqueue() — durable before the UI moves import GRDB // thin wrapper over SQLite (WAL mode) struct OutboxRow: Codable, FetchableRecord, PersistableRecord { var idempotencyKey = UUID().uuidString var payload: Data // memo, AES-GCM encrypted at rest var status = "pending" var attempts = 0 var createdAt = Date().timeIntervalSince1970 } func enqueue(_ memo: Memo) throws { try dbQueue.write { db in // atomic, crash-safe append try OutboxRow(payload: memo.encrypted()).insert(db) } // ← returns only once on disk // ONLY NOW do we touch the UI — and we render from the table, // never from an in-memory optimistic copy. NotificationCenter.default.post(name: .outboxChanged, object: nil) drainScheduler.kick() // try to send; failure is safe }
  18. Solo iOS Growth Diary · Vol. 4 · The Outbox

    18 / 35 Code · drain() — at-least-once with backoff func drain() async { let due = try? dbQueue.read { db in try OutboxRow .filter(Column("status") != "sent") .filter(Column("nextAttemptAt") ?? 0 <= Date().now) .order(Column("rowid")) // strict append order .fetchAll(db) } for row in due ?? [] { do { try await sinks.deliver(row) // all sinks, deduped on key try mark(row, status: "sent") // row becomes immutable } catch { let n = row.attempts + 1 let delay = min(pow(2, Double(n)), 300) * jitter() // 1,2,4…cap 300s try mark(row, status: "failed", attempts: n, nextAttemptAt: Date().now + delay) } } }
  19. Solo iOS Growth Diary · Vol. 4 · The Outbox

    19 / 35 Per-sink receipts · exactly-once effect One Outbox row, several idempotent delivery attempts. Each sink dedupes on the row's UUID, so a re-drain after a crash never double-delivers. Sink Outcome Dedupe Typical latency Email (SMTP, AES-GCM) ✓ sent UUID match 840 ms Obsidian daily note (iCloud) ✓ appended UUID match ~4 s incl. iCloud Local FTS index (SQLite) ✓ indexed UUID match 3 ms At-least-once delivery + UUID dedupe = exactly-once *effect* per sink. The Outbox is what makes multi-sink delivery safe: the durable row is the contract, each sink is just a retryable, idempotent projection of it.
  20. Solo iOS Growth Diary · Vol. 4 · The Outbox

    20 / 35 Anti-thesis 1 · "Just use CRDTs." They say: “Conflict-free replicated data types are the modern, correct way to sync. Reach for Yjs or Automerge and you'll never fight a merge conflict again.” Reality: CRDTs solve concurrent multi-writer merge A memo app has one writer per device, append-only So the conflict they fix never actually occurs You'd pay library size + merge UX + storage overhead For a guarantee an Outbox already gives you for ~214 LOC
  21. Solo iOS Growth Diary · Vol. 4 · The Outbox

    21 / 35 Anti-thesis 2 · "Optimistic UI is good enough." “Users want instant feedback. Render the change immediately and reconcile in the background — that's just good UX.” A g r e e d — b u t r e n d e r f r o m a d u r a b l e s o u r c e : The disagreement isn't speed — it's what you render *from* Outbox-first append in 9 ms, then render from the row. Still instant. The difference if the network fails, the memo is already durable Optimistic-only renders from memory; an OOM kill erases the truth The rule optimistic *rendering* is fine; optimistic *persistence* is not
  22. Solo iOS Growth Diary · Vol. 4 · The Outbox

    22 / 35 Anti-thesis 3 · "SQLite is overkill — use a JSON file." J S O N / p l i s t F I L E “Write the array to disk on every change.” • No atomic append — rewrites the whole file • A half-flushed write on an OOM kill truncates the tail (loses newest memos) • No crash-safe journal • No concurrent read during write • O(n) cost grows with history • You will reinvent WAL, badly S Q L i t e W A L “INSERT one row. Done.” • Atomic, crash-safe append for free • WAL survives an OOM kill mid-write • Concurrent read while draining • Battle-tested in every iPhone already • O(log n) lookups, indexed status • ~0 extra binary size (it's in iOS) • 9 ms median append in production
  23. Solo iOS Growth Diary · Vol. 4 · The Outbox

    23 / 35 Idempotency · how at-least-once becomes exactly-once A durable queue means you *will* retry — including after a crash mid-send. Retries are only safe if every delivery carries a stable key the sink can dedupe on. Outbox row key = a1b2-c3d4 attempt 1 (crash) ▶ attempt 2 (timeout) ▶ attempt 3 (ok) ▶ 1 effect sink saw the key before → ignores dupes ▶ Result in production: 3,112 rows replayed after forced-quits during send → 0 duplicate memos delivered. Server-side, the same key powers an idempotent INSERT … ON CONFLICT DO NOTHING; in a file sink, a marker comment guards against a double append.
  24. Solo iOS Growth Diary · Vol. 4 · The Outbox

    24 / 35 Cost story · Outbox vs a CRDT sync engine Append-only Outbox 214 lines of Swift one-time, ~3 dev-days Optimistic UI + rollback ~120 lines + edge-case UX ongoing bug surface Full CRDT sync engine 1000s + library + storage weeks→a quarter For a single-writer memo app, the Outbox buys CRDT-grade durability at roughly 1/20th of the engineering cost — and the saved quarter ships Vols. 5–12 instead.
  25. Solo iOS Growth Diary · Vol. 4 · The Outbox

    25 / 35 Where the Outbox is the wrong call Real-time collaborative editing Multiple concurrent writers need CRDTs or OT. The Outbox has no merge function. Strong global ordering across devices Append order is per-device. Use a server sequence number or vector clock for a total order. Huge binary payloads Don't queue a 50 MB blob in SQLite. Queue a pointer; store the blob in R2 / S3. Instant cross-device echo The Outbox is local. Cross-device propagation still waits on the sync layer (see Vol. 3). Server-authoritative validation If the backend must approve before "saved," optimistic-with-rollback may model it better. A single-user memo is none of these. That's why append-only wins here.
  26. Solo iOS Growth Diary · Vol. 4 · The Outbox

    26 / 35 Playbook · Step 1 of 7 Append the intent, not the result enqueue(memo) writes one immutable row before any UI mutation or network call. H O W enqueue(memo) { db.write { insert OutboxRow(payload, key=UUID) } // durable render() // from the table, not from memory drain.kick() } W H Y The intent is the asset. Delivery is a detail you can retry forever.
  27. Solo iOS Growth Diary · Vol. 4 · The Outbox

    27 / 35 Playbook · Step 2 of 7 One table, WAL mode PRAGMA journal_mode=WAL gives you atomic, crash-safe appends and concurrent reads for free. H O W PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; -- one append = one fsync-safe transaction W H Y WAL is already inside every iPhone. You are not adding a dependency; you are using the one you have.
  28. Solo iOS Growth Diary · Vol. 4 · The Outbox

    28 / 35 Playbook · Step 3 of 7 Render from the log The list view is a query over Outbox + synced store — never from in-memory optimistic state. H O W var visibleMemos: [Memo] { outbox.allRows().map(\.asMemo) // pending + sent, one source } W H Y If the view can only show what's durable, "looks saved" and "is saved" can never diverge again.
  29. Solo iOS Growth Diary · Vol. 4 · The Outbox

    29 / 35 Playbook · Step 4 of 7 Drain on a background task BGTaskScheduler + NWPathMonitor: drain when the network returns, not on a wasteful timer. H O W monitor.pathUpdateHandler = { path in if path.status == .satisfied { Task { await drain() } } } W H Y Event-driven drains save battery and fire at the one moment they can actually succeed.
  30. Solo iOS Growth Diary · Vol. 4 · The Outbox

    30 / 35 Playbook · Steps 5–7 (dedupe, backoff, prune) 5 Idempotency keys on every row A UUID per row lets each sink dedupe. At-least-once + dedupe = exactly-once effect. This is the single line that makes retries — and crashes during retries — safe. 6 Exponential backoff with jitter delay = min(base·2ⁿ, cap) ± jitter. Cap at ~5 min; never hot-loop a dead network. Jitter stops a fleet of devices from stampeding a server the instant it recovers. 7 Mailbox is canonical, Outbox is the buffer Once sent, the delivered copy in each sink is the durable truth; prune the row after a retention window. The buffer stays small; the durable history lives where the user already is.
  31. Solo iOS Growth Diary · Vol. 4 · The Outbox

    31 / 35 Honest scoreboard · SimpleMemo, 12 months in 100% writes durable before any network call 0 memos lost to failed writes (was 14 in v0.9) 9 ms median enqueue → durable (WAL append) 840 ms median drain → sent latency (online) 3,112 rows replayed after forced-quit · 0 duplicates 214 lines of Swift in the Outbox subsystem Source: SimpleMemo internal telemetry, 2025-06 → 2026-05. Re-printed in Vol. 12's year-one retrospective.
  32. Solo iOS Growth Diary · Vol. 4 · The Outbox

    32 / 35 Where it broke (so you don't have to find out) 2025-09-02 Pre-WAL rollback 14 memos truncated An OOM kill truncated a non-WAL write. This was the root cause of the entire Outbox rewrite. 2026-01-20 No-backoff drain 6% battery in 1 h Drain hot-looped in airplane mode. Fixed with jittered exponential backoff (Step 6). 2026-03-11 Crash mid-drain Duplicate sends Retries re-delivered. Added UUID idempotency keys; duplicates dropped to 0. 2026-05-12 iCloud vault lag ~4 s to appear A vault append raced iCloud reflection. Documented as expected behavior, not a bug. Pattern: every failure was a durability gap. Once the write was append-only, the rest became retries.
  33. Solo iOS Growth Diary · Vol. 4 · The Outbox

    33 / 35 What to copy, what to skip C O P Y • Append-only Outbox as your write path (persist, then paint) • SQLite WAL mode — atomic, crash-safe appends • A UUID idempotency key on every row • Render the list FROM the log, never from optimistic memory S K I P • CRDTs for a single-writer app (a conflict you don't have) • Optimistic UI without a durable queue behind it • JSON / plist files as your write store (no atomic append) • Timer-based polling (use NWPathMonitor instead)
  34. Solo iOS Growth Diary · Vol. 4 · The Outbox

    34 / 35 A write you can't replay was never really saved. Append first. Paint second. Sync last. — @simplememofast
  35. Solo iOS Growth Diary · Vol. 4 · The Outbox

    35 / 35 Next week · Vol. 5 V O L . 0 5 AES-GCM in 200 Lines — E2E Encryption for Solo iOS Devs Thesis: CryptoKit alone is enough. End-to-end encrypt every memo in ~200 lines — and see why the optional on-device append stays safely outside the encryption boundary. F O L L O W speakerdeck.com/simplememo x.com/simplememofast New deck every Tuesday 22:00 JST. P U B L I S H E D Vol. 1 · From EOL to Encore Vol. 2 · Speed Is The Feature Vol. 3 · Email-as-Sync Vol. 4 · The Outbox ← you are here Vol. 5–12 · weekly through Aug 2026