Skip to content

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.

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.

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.

your app IndexedDB outbox Ironflow engine network append() acked now strict FIFO, on reconnect deduped by idempotency key

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.

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.

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.

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.

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.

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 emit and streams.append only. Not invoke.
  • 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.