Streaming SSE from Cloudflare Workers Permalink to this section
Part of Edge & Serverless SSE Deployment, under Backend Stream Generation & Connection Management.
A Worker streams well β an idle stream costs almost nothing on an isolate runtime β but every example built around an in-process array of open responses breaks immediately, because consecutive requests land on different isolates. The registry has to live somewhere durable.
Symptom & Developer Intent Permalink to this section
- A Worker adapted from a Node example delivers the first event to the connecting client and nothing else.
- Events published by one request never reach clients connected through a different request.
setIntervalheartbeats stop firing after a period of inactivity.- The stream works in
wrangler devand behaves differently once deployed. - Clients reconnect cleanly but see a gap in the data.
The intent is a Worker-hosted stream where any publisher reaches every subscriber, heartbeats survive, and reconnects replay what was missed.
Root Cause Analysis Permalink to this section
Workers run in V8 isolates that are created, reused and discarded per the platformβs own scheduling. Two consequences dominate.
No shared memory across requests. A module-level Set of writers is visible only within one isolate. Two clients connecting a second apart may be served by different isolates in different data centres, so a publish handled by one isolate reaches only the subscribers that isolate happens to hold. Locally, wrangler dev runs a single isolate β which is exactly why it works in development.
No durable timers. setInterval inside a request handler runs while that request is alive, but nothing guarantees the isolate stays warm between events. Heartbeats built on bare intervals stop when the isolate is evicted, and the first symptom is streams dying at whatever idle timeout sits in front of them.
The platformβs answer to both is a Durable Object: a single-instance, addressable actor with its own storage and alarms. One object per topic owns the subscriber list, the replay buffer and the heartbeat alarm, and every Worker request routes into it. That gives a single point of coordination without a separate message bus.
Step-by-Step Resolution Permalink to this section
Step 1 β Route the stream request into a per-topic Durable Object Permalink to this section
// src/index.js β the Worker is a thin router.
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === '/events') {
const topic = url.searchParams.get('topic') ?? 'default';
const id = env.TOPIC.idFromName(topic); // same name β same instance, globally
const stub = env.TOPIC.get(id);
return stub.fetch('https://do/subscribe', {
headers: {
'Last-Event-ID':
request.headers.get('Last-Event-ID') ?? url.searchParams.get('lastEventId') ?? '',
},
});
}
if (url.pathname === '/publish' && request.method === 'POST') {
const body = await request.json();
const stub = env.TOPIC.get(env.TOPIC.idFromName(body.topic));
return stub.fetch('https://do/publish', { method: 'POST', body: JSON.stringify(body) });
}
return new Response('Not found', { status: 404 });
},
};
Step 2 β Hold subscribers and the replay buffer in the Durable Object Permalink to this section
// src/topic.js
const encoder = new TextEncoder();
const frame = (e) =>
`id: ${e.id}\nevent: ${e.type ?? 'message'}\ndata: ${JSON.stringify(e.data)}\n\n`;
export class Topic {
constructor(state) {
this.state = state;
this.subscribers = new Set();
this.recent = []; // bounded replay buffer
this.seq = 0;
}
async fetch(request) {
const url = new URL(request.url);
if (url.pathname === '/publish') return this.publish(await request.json());
return this.subscribe(request.headers.get('Last-Event-ID'));
}
subscribe(lastEventId) {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const client = {
write: (text) =>
writer.write(encoder.encode(text)).catch(() => this.subscribers.delete(client)),
};
this.subscribers.add(client);
client.write('retry: 3000\n\n'); // control the reconnect delay
if (lastEventId) {
const from = this.recent.findIndex((e) => String(e.id) === lastEventId);
if (from === -1) {
client.write('event: resync\ndata: {"reason":"buffer_expired"}\n\n');
} else {
for (const e of this.recent.slice(from + 1)) client.write(frame(e));
}
}
this.scheduleHeartbeat();
return new Response(readable, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
},
});
}
async publish({ type, data }) {
const event = { id: ++this.seq, type, data };
this.recent.push(event);
if (this.recent.length > 500) this.recent.shift();
const text = frame(event);
for (const c of this.subscribers) c.write(text);
return new Response(null, { status: 204 });
}
// ββ heartbeat via a durable alarm, not setInterval ββββββββββββββββββββββ
async scheduleHeartbeat() {
if (!(await this.state.storage.getAlarm())) {
await this.state.storage.setAlarm(Date.now() + 15_000);
}
}
async alarm() {
for (const c of this.subscribers) c.write(': ping\n\n');
if (this.subscribers.size) await this.state.storage.setAlarm(Date.now() + 15_000);
}
}
Step 3 β Declare the binding and migration Permalink to this section
# wrangler.toml
name = "sse-worker"
main = "src/index.js"
compatibility_date = "2026-01-01"
[[durable_objects.bindings]]
name = "TOPIC"
class_name = "Topic"
[[migrations]]
tag = "v1"
new_classes = ["Topic"]
Step 4 β Drop subscribers whose writes fail Permalink to this section
A client that disappears leaves a writer whose write() rejects. The catch in step 2 removes it, but a periodic sweep is worth adding for the case where writes are rare:
// Inside Topic β prune on every heartbeat, when a write is guaranteed to happen.
async alarm() {
for (const c of [...this.subscribers]) {
try { await c.write(': ping\n\n'); } catch { this.subscribers.delete(c); }
}
if (this.subscribers.size) await this.state.storage.setAlarm(Date.now() + 15_000);
}
Step 5 β Deploy and confirm cross-request fan-out Permalink to this section
wrangler deploy
# Terminal 1 β subscribe.
curl -N https://sse-worker.example.workers.dev/events?topic=prices
# Terminal 2 β publish from a completely separate request.
curl -X POST https://sse-worker.example.workers.dev/publish \
-H 'Content-Type: application/json' \
-d '{"topic":"prices","type":"price","data":{"eur":1.08}}'
# The event must appear in terminal 1.
That cross-request delivery is the test that distinguishes a working design from the in-memory one that only ever worked locally.
Validation & Monitoring Permalink to this section
# 1. Two subscribers, one publish β both must receive it.
curl -N https://β¦/events?topic=prices > /tmp/a.txt &
curl -N https://β¦/events?topic=prices > /tmp/b.txt &
sleep 1
curl -sX POST https://β¦/publish -d '{"topic":"prices","type":"price","data":{"eur":1.09}}'
sleep 1 && grep -c 1.09 /tmp/a.txt /tmp/b.txt # expect 1 and 1
# 2. Heartbeats keep arriving through an idle period.
timeout 70 curl -N -s https://β¦/events?topic=prices | grep -c '^: ping' # expect ~4
# 3. Replay works after a reconnect.
curl -N -s -H 'Last-Event-ID: 3' https://β¦/events?topic=prices | head -3
# Expect ids 4, 5, β¦ with no gap.
For production signal, wrangler tail shows live logs per request, and the platformβs analytics report request counts and CPU time. The metric to watch on an isolate runtime is CPU time per request rather than wall time: a stream that does per-event work β parsing, transforming, signing β burns budget in a way an idle stream does not, and that is what eventually ends long connections.
Record a resync counter as well. A rising rate means the 500-event replay buffer is too small for real disconnect durations, and the fix is a larger buffer or a durable log rather than anything on the client.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Why does my in-memory subscriber list work locally but not once deployed?
wrangler dev runs a single isolate, so module scope is effectively shared. In production, requests are served by many isolates across many locations, and a publish handled by one of them cannot see subscribers held by another. Move the registry into a Durable Object, which is a single addressable instance for a given name.
How long can a Worker hold an SSE connection open?
Longer than most people expect, because the limit is CPU time rather than wall-clock time and an idle stream uses almost none. Streams doing meaningful per-event work consume budget faster. Treat a planned handover β closing deliberately with a retry: hint β as part of the design rather than relying on any specific ceiling.
Do I need Durable Objects, or can I use an external bus?
Either works. A Durable Object gives you the registry, the replay buffer and durable alarms in one primitive with no extra infrastructure. An external bus such as Redis is the right call when publishers live outside the Worker platform and you already operate one β the trade is an extra network hop and a second system to run.
Why use an alarm instead of setInterval for heartbeats?
Because an interval lives only as long as the isolate that created it, and isolates are evicted between events. An alarm is durable storage state: it fires even if the object was evicted and has to be re-instantiated, which is what keeps quiet streams alive through the idle timeouts in front of them.