Serving SSE with Bun and Deno Permalink to this section
Part of Node.js Streaming Architecture Basics, under Backend Stream Generation & Connection Management.
Bun and Deno both expose HTTP servers built on Web-standard Request and Response objects, so a Server-Sent Events endpoint is the same shape in each: return a Response whose body is a ReadableStream of encoded frames. The differences are in the runtime defaults around that shape — most notably Bun’s short default idle timeout — and in how each exposes client disconnects. This guide writes one portable handler and then covers what each runtime needs around it.
Symptom & Developer Intent Permalink to this section
- On Bun, idle streams close after about ten seconds and the browser reconnects in a loop.
- On Deno, the server logs “connection closed before message completed” errors for every departing client.
- Producers keep running after clients leave, because the stream’s
cancelis never observed. - Code ported from Node.js
res.writestyle does not run at all. - Heartbeats are sent, but events still arrive late behind a proxy.
The intent is a handler that runs unchanged on Bun and Deno (and on edge runtimes with the same APIs), keeps idle streams open, and stops work when clients disconnect.
Root Cause Analysis Permalink to this section
Both runtimes pull from the response body’s ReadableStream. The stream reports a departing client through its cancel() callback and through request.signal. Code that only pushes into the controller, without handling either, leaks producers.
Bun’s Bun.serve closes connections that have been idle for idleTimeout seconds — ten by default. An SSE stream that is quiet for ten seconds is idle by that definition, so without a faster heartbeat or a changed timeout it is closed and reconnects repeatedly.
Step-by-Step Resolution Permalink to this section
Step 1 — Write one Web-standard handler Permalink to this section
// sse.ts — shared by Bun and Deno
const enc = new TextEncoder();
export function sseResponse(req: Request, subscribe: (send: (e: Evt) => void) => () => void): Response {
let cleanup = () => {};
const stream = new ReadableStream<Uint8Array>({
start(controller) {
const write = (s: string) => {
try { controller.enqueue(enc.encode(s)); } catch { cleanup(); } // closed stream
};
write('retry: 3000\n\n');
const unsubscribe = subscribe((e) => write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`));
const hb = setInterval(() => write(': hb\n\n'), 8_000); // under Bun's 10 s default
cleanup = () => { clearInterval(hb); unsubscribe(); cleanup = () => {}; };
req.signal.addEventListener('abort', () => cleanup());
},
cancel() { cleanup(); }, // consumer went away
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no',
},
});
}
Cleanup is idempotent and wired to both cancel() and the abort signal, because which one fires first varies by runtime and by how the client left.
Step 2 — Serve it with Bun Permalink to this section
// server.bun.ts
import { sseResponse } from './sse';
const server = Bun.serve({
port: 3000,
idleTimeout: 30, // seconds; still send heartbeats well inside it
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === '/events') {
server.timeout(req, 0); // disable the idle timeout for this request only
return sseResponse(req, (send) => bus.subscribe(send));
}
return new Response('not found', { status: 404 });
},
});
server.timeout(req, 0) disables the idle timeout for the stream request while leaving it in force for ordinary requests. The 8-second heartbeat in the shared handler covers deployments where that call is not available or the timeout is enforced by a proxy instead.
Step 3 — Serve it with Deno Permalink to this section
// server.deno.ts
import { sseResponse } from './sse.ts';
Deno.serve({ port: 3000 }, (req) => {
const url = new URL(req.url);
if (url.pathname === '/events') return sseResponse(req, (send) => bus.subscribe(send));
return new Response('not found', { status: 404 });
});
Deno logs an error when a response body is still streaming as the connection closes. With cancel() implemented, the stream stops cleanly; remaining log noise can be filtered in the onError handler of Deno.serve.
Step 4 — Respect backpressure with a pull-based stream when producing fast Permalink to this section
The start-and-enqueue pattern queues everything the producer emits. For high-rate producers, use pull with a small highWaterMark, so the runtime asks for data only as the client consumes it:
new ReadableStream<Uint8Array>({
async pull(controller) {
const e = await queue.next(); // waits until the client can take more
controller.enqueue(enc.encode(frameOf(e)));
},
cancel() { queue.close(); },
}, { highWaterMark: 16 });
Combine this with coalescing for state-shaped data, as in dropping vs coalescing events under backpressure.
Step 5 — Feed the bus from a broker and deploy behind a proxy Permalink to this section
A single Bun or Deno process holds its subscribers in memory, so in any deployment with more than one instance the bus in the examples must be fed from a shared broker. Both runtimes can run the common Redis and NATS clients: Bun runs most npm packages directly and also ships a built-in Redis client, and Deno imports npm packages with the npm: specifier. Subscribe once per process at startup and publish into the in-process bus from that subscription, exactly as a Node.js service would; per-request broker subscriptions multiply broker connections by the number of open streams.
Behind nginx, Caddy or a cloud load balancer, the proxy’s own timeouts apply in addition to the runtime’s. The 8-second heartbeat chosen for Bun’s default comfortably beats every common proxy idle timeout, so the same value works everywhere. Disable proxy buffering for the route and keep compression off for text/event-stream; Deno Deploy and other managed hosts that run these runtimes may add their own request duration limits, in which case the reconnect-driven handover from the serverless timeout guide applies unchanged.
Finally, graceful shutdown: both runtimes let you stop the server (server.stop() in Bun, the AbortSignal passed to Deno.serve in Deno). Before stopping, end open streams with a short retry: hint so clients reconnect promptly to another instance instead of waiting for their connections to time out.
Validation & Monitoring Permalink to this section
# Idle survival on Bun: no events published, the stream must stay open past 60 s.
timeout 70 curl -sN http://localhost:3000/events | grep -c '^:' # ≥ 7 heartbeats, no reconnect
# Cleanup: open and kill 100 streams, then check subscribers.
for i in $(seq 1 100); do timeout 1 curl -sN http://localhost:3000/events > /dev/null & done; wait
curl -s http://localhost:3000/debug/subscribers # expect 0
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Does Bun support Node's res.write API?
Bun implements node:http for compatibility, so Node-style handlers run. For new code, Bun.serve with a ReadableStream is faster and portable to Deno and edge runtimes.
Is there an SSE helper in Deno's standard library?
Deno's standard library has had helpers for server-sent event streams, but the ReadableStream approach needs no dependency and makes cleanup explicit, which is the part that matters.
Why use both cancel() and request.signal?
They report the same departure through different paths, and runtimes differ in which fires and when. Wiring both to one idempotent cleanup guarantees it runs exactly once.
Can these handlers run on Cloudflare Workers?
The response shape is the same, and Workers support ReadableStream bodies. Event sources differ — Workers typically use Durable Objects for fan-out — as covered in the edge deployment guides.