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:
- Multiple SSE streams (capacity → notifications → attendance) — the single-poll pattern doesn't scale linearly
- 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)
- 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:
- Phase 1 — Capacity write path (prerequisite): wire
session_capacity.occupiedupserts into check-in/check-out handlers. The existing 5s poll starts showing real data immediately. This is pure plumbing, independent of DOs. - Phase 2 — Capacity DO: replace the capacity 5s poll with a DO that receives capacity-change events and fans out to connected SSE clients.
- Phase 3 — Notifications DO: replace the notifications 3s poll with a DO.
- 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
EventSourcereconnection 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 unchanged —
EventSourceremains, 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
EventSourceauto-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
| Alternative | Reason 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 + DO | Frontend would need WebSocket client rewrite; SSE is simpler for one-way server→client push |
| D1 + DO hybrid broker | Extra complexity — DO as broker adds an indirection without benefit over DO as direct fan-out hub |
References
- Issue #380 (epic): https://github.com/barateza/neemias/issues/380
- ADR-0020 (superseded):
docs/architecture/adr/ADR-0020.md - Cloudflare Durable Objects docs: https://developers.cloudflare.com/durable-objects/
- Cloudflare DO WebSocket Hibernation: https://developers.cloudflare.com/durable-objects/api/websocket-hibernation/
- Capacity design doc:
docs/en/architecture/sdd-149-room-capacity.md - Notification SSE spec:
.specs/features/notifications/spec.md