C.W.K.
Stream
Lesson 03 of 04 · published

IndexedDB as the Durable Outbox

~13 min · indexeddb, outbox, storage, blobs

Level 0Dead Zone
0 XP0/32 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"The outbox is the whole trust model in one object store: things you've committed, waiting to be delivered."

Why IndexedDB, Not localStorage

For durable phone-side capture, the storage choice isn't cosmetic. localStorage is synchronous, string-only, and small — it blocks the main thread and can't hold image blobs. IndexedDB is the right tool: it's asynchronous (never freezes the UI), transactional (the atomic aggregate from the last lesson depends on it), and it stores structured objects and binary Blobs directly. For a client whose whole job is holding questions-with-images safely across app restarts, that combination isn't a nice-to-have — it's the foundation.

The Outbox Pattern

The core structure is an outbox: a durable queue of captured turns, each tagged with a sync status. It's the messaging-world outbox pattern applied on-device. The flow is a loop that's safe to interrupt at any point:

  • Enqueue: a submitted turn lands in the outbox as pending, atomically with its blobs.
  • Drain: a worker picks up pending turns and attempts delivery, moving them to sending, then answered or failed.
  • Resume: on app launch, the worker re-scans the outbox for anything not yet answered and continues where it left off.

Because the outbox is durable, none of these steps assumes the previous one finished in the same session. Close the app between enqueue and drain, and the drain simply happens next launch. The queue is the memory.

The Outbox Is Ground Truth for the Phone

On the device, the outbox isn't a cache of what the server knows — it's the authoritative record of what the user has asked, independent of whether the server has heard yet. That's why it can survive offline for hours: it doesn't need the server to be the source of truth for local state. (The backend remains the source of truth for canonical conversations — the outbox is the phone's durable capture layer, a different job. Track 5 draws that line precisely.)

Code

Draining the outbox, safe to resume·typescript
// Runs on submit, on reconnect, and on app launch. Idempotent by design.
async function drainOutbox() {
  const pending = await db.getAllFromIndex("turns", "by-status", "pending");
  for (const turn of pending) {
    await setStatus(turn.deviceTurnId, "sending");
    try {
      const answer = await deliver(turn); // reads blobs from the store
      await saveAnswer(turn.deviceTurnId, answer, "answered");
    } catch {
      await setStatus(turn.deviceTurnId, "failed"); // stays in outbox, retryable
    }
  }
}

// Kill the app mid-loop and the survivors are still 'pending' or 'sending'
// in a durable store — the next drainOutbox() picks them right back up.

External links

Exercise

Design the object stores for a Pippa Go outbox: what's the key path for turns, what index lets you query 'all pending', and how do you store image blobs so they commit in the same transaction as the turn? Sketch the schema, then trace one turn from enqueue to answered, naming its status at each step.
Hint
A 'turns' store keyed by deviceTurnId, an index on syncStatus for the drain query, and a 'blobs' store written in the same transaction. The status walk is pending → sending → answered (or failed), and every arrow is a durable write.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.