Push Real-Time Updates to the Browser Over a Long-Lived HTTP Connection
Server-Sent Events
Implement Server-Sent Events in Koa 2 using a PassThrough stream and the text/event-stream content type, and add a heartbeat to keep the connection alive through proxies and load balancers.
What you'll learn
- Set up an SSE endpoint that keeps the HTTP connection open and pushes events
- Format event-stream messages correctly with data, event, and id fields
- Send periodic heartbeat comments to prevent connection timeouts
Server-Sent Events (SSE) let the server push updates to a browser over a single
long-lived HTTP connection. The browser’s built-in EventSource API reconnects
automatically if the connection drops. SSE is ideal for live feeds, progress
bars, and notification streams that only flow server-to-client.
The event-stream Protocol
The Content-Type must be text/event-stream. Each message is one or more
field: value lines followed by a blank line.
| Field | Purpose | Example |
|---|---|---|
| data | The payload (required) | data: {"count":42} |
| event | Named event type | event: priceUpdate |
| id | Reconnect cursor | id: 1718000000 |
| retry | Client reconnect delay (ms) | retry: 3000 |
| : (comment) | Keep-alive / heartbeat | : ping |
Basic SSE Endpoint
import Router from '@koa/router';
import { PassThrough } from 'node:stream';
const router = new Router();
router.get('/events', async (ctx) => {
const stream = new PassThrough();
ctx.set('Content-Type', 'text/event-stream');
ctx.set('Cache-Control', 'no-cache');
ctx.set('Connection', 'keep-alive');
ctx.set('X-Accel-Buffering', 'no'); // disable Nginx response buffering
ctx.status = 200;
ctx.body = stream;
// Helper to format an SSE message
const send = (data, event) => {
if (event) stream.write(`event: ${event}\n`);
stream.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Heartbeat every 25 seconds to keep proxies happy
const heartbeat = setInterval(() => stream.write(': ping\n\n'), 25_000);
// Example: push a counter every second
let count = 0;
const ticker = setInterval(() => {
send({ count: ++count }, 'tick');
}, 1_000);
// Clean up when the client disconnects
ctx.req.on('close', () => {
clearInterval(heartbeat);
clearInterval(ticker);
stream.end();
});
});
export default router; Why ctx.respond = false Is Not Needed Here
Assigning a stream to ctx.body is the idiomatic Koa way to stream responses —
Koa detects the stream and pipes it. Setting ctx.respond = false bypasses
Koa’s response handling entirely and is only needed when you need raw access to
the res socket (rare).
Browser Client
const source = new EventSource('/events');
source.addEventListener('tick', (e) => {
const { count } = JSON.parse(e.data);
console.log('tick', count);
});
source.onerror = () => {
// EventSource reconnects automatically with exponential backoff
}; Scaling Across Multiple Processes
A single Koa process holds SSE connections in memory. To broadcast events across
multiple instances, use a pub/sub channel — Redis SUBSCRIBE works well. Each
process subscribes to the channel and forwards messages to its open streams.
Up Next
Learn WebSockets for bidirectional real-time communication, where the client also sends messages to the server.
WebSockets in Koa →