- Posts
- Your Todo App Works in a Tunnel
Your Todo App Works in a Tunnel
A user opens your todo app on the train. They add a task, tick two off, delete one. The train enters a tunnel.
Before v0.28.0, @ironflow/browser had nothing to say about this. Every write was
a bare network call. Offline, the promise rejected. Your app either showed an
error on a task the user had already watched disappear, or you wrote your own
outbox.
Most teams write their own outbox. It is always the same outbox, and it is always slightly wrong.
Turn it on
Section titled “Turn it on”const app = await createClient({ serverUrl: 'https://ironflow.example.com', auth: { apiKey: import.meta.env.VITE_IRONFLOW_KEY }, offlineQueue: { identity: currentUser.id, // required },});createClient is async because the outbox must be open before the first write
can be answered honestly. It returns an OfflineClient. Only emit and
streams.append are wrapped — the full client is on .client.
That is the whole opt-in.
The three writes
Section titled “The three writes”A todo list is an entity stream. Each action appends one event:
await app.streams.append(listId, { entityType: 'todolist', name: 'todo.created', data: { todoId, title },});
await app.streams.append(listId, { entityType: 'todolist', name: 'todo.toggled', data: { todoId, done: true },});Online, these go straight out. In the tunnel, they go to IndexedDB and return immediately. The user sees no difference, because there is none to see.
Order is strict FIFO. That matters here more than it looks: a todo.toggled that
lands before its todo.created is not a slow write, it is a corrupt list. The
queue blocks rather than skips.
The badge
Section titled “The badge”queue.stats() gives you a snapshot, queue.subscribe() gives you a stream:
function useQueueStats(app) { const [stats, setStats] = useState(() => app.queue.stats()); useEffect(() => app.queue.subscribe(setStats), [app.queue]); return stats;}
const { pending } = useQueueStats(app);return pending > 0 ? <span>{pending} unsynced</span> : null;subscribe returns its own unsubscribe, so it drops straight into useEffect.
app.queue is stable across reads, so the dependency array does not thrash.
What the SDK does not do
Section titled “What the SDK does not do”It saves your writes. It does not keep your list readable.
The outbox is write-only. Go offline, reload the tab, and the list is empty
unless your app stored it. Local view state is still your job — useState plus
a localStorage mirror, ten lines or so. What you no longer write is the durable,
ordered, deduplicated, cross-tab outbox underneath it. That is the part that was
always slightly wrong.
What you give up
Section titled “What you give up”Two things, both deliberate.
No expectedVersion. A queued append is forced to expected_version: -1, and
passing anything else throws at call time. A version you read before the tunnel is
stale by the time the tunnel ends, so the append would always conflict. Offline,
you get an ordered append-only log and no concurrency check. For a todo list that
is the right trade. For a seat reservation it is not — write those through
client.streams.append and let them fail honestly.
No runIds. A queued write answers before the server has seen it, so no run
exists yet to have an id. You get a localId instead, and queue.watch(localId, cb) reports { status: 'sent', eventId, entityVersion } once it lands. If your
UI is “I triggered a job, take me to its run page,” that pattern does not survive
the queue. Watch the projection instead.
When a write will never send
Section titled “When a write will never send”Some writes are not slow, they are dead. A permanent 4xx, a record past its retention window, a write queued under a different user. Those move to a dead-letter store instead of blocking the queue forever:
const lost = await app.queue.deadLetter(); // oldest first
for (const entry of lost) { console.log(entry.write.localId, entry.reason, entry.message);}Each one is then yours to resolve, one way or the other — never both.
queue.retry(localId) puts it back on the queue with its original dedup key, so a
write that did land is not duplicated. queue.discard(localId) forgets it for
good. There is also an onWriteLost(write, reason, message) callback for the
toast. The point of all three is that a failed write is a thing you can look at,
not a silence.
What this is not
Section titled “What this is not”Be precise about this one, because your users will not be.
- It is not background sync. Nothing drains while the tab is closed. Writes survive a reload, a crash, and a tab discard — they are delivered the next time the app is open. Tell users “saved, and sent when you next open the app.” Never “sent in the background.”
- It covers
emitandstreams.appendonly. Notinvoke. - It is at-least-once, not exactly-once. Deduplication is the engine’s idempotency key.
- It is not encrypted at rest. IndexedDB is readable by anything with access to the origin’s profile. Do not queue secrets.
- Storage is best-effort. Safari evicts an origin after roughly seven days of no interaction.
The cap is 500 items or 5 MB, and hitting it throws QueueFullError rather than
dropping the oldest write. Every telemetry SDK does the opposite, because losing a
pageview is fine. Losing a todo.created while keeping its todo.toggled is how
you manufacture the exact corrupt list that FIFO exists to prevent.
The full API is in the browser SDK
reference, and the reasoning is
in ADR 0053. If you would rather read a running app than a post,
examples/travel-booking books a flight and a hotel as one saga and now uses
this queue for real — turn on DevTools → Network → Offline and book a trip.