Realtime retail with Durable Objects: live viewers, stock broadcasts and atomic reservations


“3 people viewing” and “1 left in stock” appearing live on a product grid — with no polling — is the fastest way to make a storefront feel alive. In the Lumina demo, one Durable Object does presence, broadcasts and checkout reservations. This post is how, and why one.

The story

A Durable Object is a single-threaded class with its own SQLite storage — a natural home for anything that must be coordinated in one place. The demo has one class, RetailHub, and one instance ("live"). Why one instance instead of one per product? Because checkout reservations span multiple products — “1 coat + 1 scarf” must be checked atomically against both — and there are no cross-DO transactions. A single coordination point makes the whole checkout race one check. (The scale-up path — per-product DOs plus a checkout coordinator — is sketched in the demo’s README; the demo runs fine at its size without it.)

How it works

Presence with the hibernation API. Browsers connect a WebSocket through the Worker to the DO, and each socket is accepted with a tag:

const pair = new WebSocketPair();
const clientId = crypto.randomUUID();
this.ctx.acceptWebSocket(pair[1], [`c:${clientId}`]);   // tag = identity
this.ctx.storage.sql.exec('INSERT OR IGNORE INTO clients (client_id) VALUES (?)', clientId);
pair[1].send(JSON.stringify({ type: 'snapshot', products: this.listProducts() }));

Each browser then sends interests messages — the product ids currently on screen (IntersectionObserver, debounced). The DO stores them in SQLite, computes viewer counts, and broadcasts deltas. The hibernation API matters twice: idle sockets cost nothing (they wake on incoming messages), and presence survives hibernation because interests are persisted, with getTags(ws) recovering identity after a wake-up.

Reservations: the live layer, not the ledger. Before stock decrements, checkout asks the DO to reserve:

async reserve(items, ttlMs = 90_000): Promise<ReserveResult> {
  const reserved = this.reservedByProduct();
  for (const [productId, qty] of merged) {
    const available = this.cachedStock(productId) - (reserved.get(productId) ?? 0);
    if (qty > available) return { ok: false, productId, available: Math.max(0, available) };
  }
  // insert reservation rows + arm alarm at expiry
  await this.ctx.storage.setAlarm(expiresAt);
  this.broadcastAvailability([...merged.keys()]);   // availability drops live
  return { ok: true, reservationId };
}

Availability = cached stock − active reservations, pushed to every open browser instantly. An abandoned checkout holds its reservation for 90 seconds, then a Durable Object alarm deletes it and broadcasts the restored availability. Alarms are the under-appreciated primitive here: a persisted timer that survives eviction.

D1 stays the source of truth. The stock cache is a broadcast cache, not the ledger: the conditional stock >= qty decrement in D1 (previous post) remains the real oversell guard, and every mutation path — checkout commit, admin restock, DB reset — re-syncs the cache from D1 before broadcasting. If the DO is unreachable, checkout falls back to the D1-only guard with a warning log. The realtime layer can be wrong; the ledger can’t.

async stockChanged(productId: string, stock: number): Promise<void> {
  this.ctx.storage.sql.exec(
    'INSERT OR REPLACE INTO stock_cache (product_id, stock) VALUES (?, ?)', productId, stock);
  this.broadcast({ type: 'stock', productId, stock: this.availableFor(productId) });
}

What the demo shows

Two windows side by side: move a product card into view in window A — window B’s viewer count for that product ticks up within a beat. Buy the last watch in one window — availability drops to zero in the other while the checkout is still running. Admin restock pushes to every open browser immediately.

Evidence: what to capture

  • Two browsers side by side, viewer count visible on a product card → 08-viewer-count.png
  • One window checks out, the other shows availability drop mid-flight → 08-stock-drop.png
  • Abandon a checkout → ~90 s later availability restores (alarm release) → 08-alarm-release.png (before/after)
  • Admin restock propagating live to the other browser → 08-restock-broadcast.png
  • Cloudflare dashboard → Workers & Pages → the retail Worker → Durable Objects tab (RetailHub metrics: WebSocket count, storage) → 08-do-dashboard.png
  • The durable-objects-flow diagram: open Blog/src/assets/diagrams/durable-objects-flow.excalidraw in Excalidraw, export PNG → 08-durable-objects-realtime/durable-objects-flow.png (embedded above)

Key takeaways

  • Anything needing a single atomic decision (multi-product checkout) is a Durable Object, not distributed guesswork.
  • Hibernation makes WebSocket presence nearly free at idle; SQLite-backed state survives hibernation and eviction.
  • DO caches push UX; the ledger (D1) remains the truth. Design the failure direction deliberately.
  • Alarms are persisted timers — perfect for TTL-style releases without cron or polling.