Node.jsintermediate
Server-Sent Events (SSE) with plain res.write, no socket.io
Published June 25, 2027
import { createServer, type ServerResponse } from "node:http";
const clients = new Set<ServerResponse>();
const server = createServer((req, res) => {
if (req.url !== "/events") return res.end();
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
clients.add(res);
req.on("close", () => clients.delete(res));
});
setInterval(() => {
const payload = `data: ${JSON.stringify({ time: Date.now() })}\n\n`;
for (const client of clients) client.write(payload);
}, 1000);
server.listen(3000);What
An SSE endpoint that keeps a Set of open connections and pushes a JSON event to every connected client once a second, using nothing but Node's built-in http module.
Why it matters
WebSockets are bidirectional and need a protocol upgrade handshake — often overkill when all you actually need is server-to-client push: a live dashboard, a progress bar, a notification feed. Server-Sent Events do that with plain HTTP: set the right headers, never call res.end(), and keep writing to the response over time. The browser's EventSource API handles reconnection automatically on the client side, so there's no library on either end.
How it works
- Setting the three headers (
Content-Type: text/event-stream,Cache-Control: no-cache,Connection: keep-alive) and never callingres.end()is what turns a normal HTTP response into a long-lived stream — the connection stays open andres.write()pushes new chunks as they happen. - Every event must be written as
data: <payload>\n\n— the SSE spec's message delimiter is a double newline; a single newline won't flush the event to the client. - Open connections are tracked in a
Set<ServerResponse>so the sharedsetIntervalcan broadcast to all of them at once.req.on("close", ...)removes a client the moment it disconnects, soclientsnever accumulates dead sockets.
Gotchas
- Browsers auto-reconnect a dropped
EventSource, but proxies and load balancers with aggressive idle timeouts can silently kill a "keep-alive" connection they think is inactive. If you see unexpected disconnects in production, send a periodic comment ping (: keepalive\n\n) to keep the connection visibly active. - This channel is one-way. If the client needs to send data back over the same connection, reach for WebSockets instead — SSE has no client-to-server half.
clientslives in one process's memory. Behind a load balancer with multiple server instances, a broadcast only reaches clients connected to that specific instance — you'd need a shared pub/sub layer (Redis, etc.) to fan out across all of them.
Related
- WebSockets — bidirectional, but needs its own protocol handling; SSE is the simpler tool when the server only needs to push.
createTokenBucket— a different single-purpose Node utility with zero dependencies, same "you don't always need a library" theme.