Skip to content

ADR-0032: Durable Objects for Real-Time Event Fan-Out

Status: accepted
Date: 2026-07-29
Deciders: @barateza
Supersedes: ADR-0020
Tags: [sse, notifications, real-time, durable-objects, capacity]

Context

The app has two Server-Sent Events (SSE) streams — notifications feed (3s D1 poll) and capacity feed (5s D1 poll) — plus a planned attendance event stream. All currently use D1 polling, which was chosen in ADR-0020 as the simplest approach for an MVP targeting church TV screens.

Since ADR-0020 (2026-06-28), requirements have expanded:

  1. Multiple SSE streams (capacity → notifications → attendance) — the single-poll pattern doesn't scale linearly
  2. Notification fan-out — one admin acts, all connected screens see it instantly. The 3s latency from polling, while acceptable for a single screen, creates a disjointed experience when multiple screens should update simultaneously (e.g., capacity dashboard + check-in tablet + TV overlay)
  3. D1 read load — each 3s poll across N connected clients generates N reads per interval, even when no new data exists

Cloudflare Durable Objects (DOs) provide a stateful coordinator at the edge with transactional storage, WebSocket Hibernation API, and zero-cost idle connections — fitting this new requirement profile.

Decision

Replace D1 polling with Durable Objects for all real-time event streams, in a phased rollout:

  1. Phase 1 — Capacity write path (prerequisite): wire session_capacity.occupied upserts into check-in/check-out handlers. The existing 5s poll starts showing real data immediately. This is pure plumbing, independent of DOs.
  2. Phase 2 — Capacity DO: replace the capacity 5s poll with a DO that receives capacity-change events and fans out to connected SSE clients.
  3. Phase 3 — Notifications DO: replace the notifications 3s poll with a DO.
  4. Phase 4 — Attendance event DO: add a new SSE stream for attendance change events.

Architecture

Admin action → Worker handler
    ├── writes to D1 (source of truth)
    └── sends event to DO via fetch()
            └── DO fans out to all connected EventSource clients
  • Each event stream gets its own DO class (CapacityFeedDO, NotificationFeedDO, AttendanceFeedDO)
  • Frontend keeps EventSource — no client-side migration to WebSocket
  • D1 remains the source of truth — the DO is a push notification layer, not a write-through cache
  • DO uses the Alarms API for periodic health checks and stale connection cleanup

Key design constraints

  • DO cold start: first client to reconnect after idle triggers a DO wake. Acceptable for church usage patterns (sessions are contiguous during service times).
  • DO-to-D1 write path: the DO does NOT write to D1. Writes always go through the Worker handler → D1 directly. The DO only reads event metadata to push to clients.
  • Graceful degradation: if the DO is unreachable, clients fall back to polling (same 3s/5s loop). The EventSource reconnection logic already handles this.

Consequences

Positive

  • Sub-second delivery — events reach all connected clients within ~100ms instead of up to 3s
  • Reduced D1 reads — no polling queries on idle channels; one DO fetch per event instead of N polls per interval
  • Unified fan-out pattern — all three event streams follow the same architecture
  • Client-side unchangedEventSource remains, no WebSocket migration
  • Graceful degradation — polling fallback if DO is cold/crashed

Negative

  • Infrastructure complexity — DO bindings in wrangler.toml, DO classes to maintain, DO-specific testing patterns
  • Cold start latency — first request to an idle DO incurs wake-up time (~100-200ms)
  • Stateful debugging — DOs have persistent state that can be harder to reproduce in dev than stateless polling
  • Deploy considerations — DO migrations (stubs) need care; in-flight connections disconnect during deploy (mitigated by EventSource auto-reconnect)

Mitigations

  • Start with a single DO class to prove the pattern before expanding to all three streams
  • DO alarms for connection health checks (remove stale connections)
  • EventSource reconnection handles deploys gracefully (same as today)

Alternatives considered

AlternativeReason to reject
Keep all polling (status quo)Doesn't scale to three streams; 3s latency becomes noticeable with multiple screens
In-memory connection pool (per Worker)Lost on deploy; broken with multi-instance Workers (same objections as ADR-0020)
WebSocket migration + DOFrontend would need WebSocket client rewrite; SSE is simpler for one-way server→client push
D1 + DO hybrid brokerExtra complexity — DO as broker adds an indirection without benefit over DO as direct fan-out hub

References

Distribuído sob licença MIT.