Server-Sent Events

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.

4 min read Level 3/5 #koa#data#sse
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.

FieldPurposeExample
dataThe payload (required)data: {"count":42}
eventNamed event typeevent: priceUpdate
idReconnect cursorid: 1718000000
retryClient 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 →