WebSockets & Server-Sent Events

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise (the Redis examples ran against a throwaway redis:8 container). Plain HTTP is request and response: the client asks, the server answers, and the exchange ends. Real-time features such as the Bookshelf application’s "new review" notification need the server to speak first, and there are two standard ways to do that. A WebSocket upgrades an HTTP connection into a long-lived, bidirectional message channel (RFC 6455). Server-Sent Events (SSE) keep an ordinary HTTP response open and stream text events down it, one way only. Node.js ships the WebSocket client built in (a global WebSocket, since v22.0.0 without a flag and stable since v22.4.0), but no WebSocket server: for that you either handle the 'upgrade' event of node:http yourself or use the ws library. SSE needs nothing beyond node:http. The last part of the page is about what changes when one process becomes several: a message published in one process has to reach the clients connected to another, which is solved with BroadcastChannel between threads and Redis Pub/Sub between processes or machines. The running scenario is described in Introduction & Architecture; the HTTP server underneath is covered in HTTP Server Fundamentals, and the client-side counterpart for plain requests in Fetch & HTTP Clients.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

Global WebSocket class (client)

No stability label in the docs (stable)

Added in v21.0.0 and v20.10.0 behind --experimental-websocket; the flag was dropped in v22.0.0; "no longer experimental" in v22.4.0. It is implemented by Undici. Disable it with --no-experimental-websocket.

http.Server 'upgrade' event

Stable

Added in v0.1.94. Since v24.9.0 and v22.21.0 the shouldUpgradeCallback option decides whether the event fires, and an accepted upgrade with no listener destroys the socket. In v26.0.0 request bodies are no longer exposed raw on the stream argument.

EventSource global (SSE client)

1 Experimental

Added in v22.3.0 and v20.18.0. Only exists with --experimental-eventsource; without the flag the identifier is not defined.

worker_threads.BroadcastChannel, global BroadcastChannel

Stable

Added in v15.4.0, no longer experimental in v18.0.0. Reaches threads of the same process only.

ws (npm package, not part of Node.js)

Third party

8.22.0 was the latest on 2026-10-03; its documentation lives in the project repository.

redis (node-redis, npm package, not part of Node.js)

Third party

6.3.0 was the latest on 2026-10-03; used here for Pub/Sub only.

WebSocket or Server-Sent Events

The two are often presented as rivals, but they solve different problems. Pick SSE when the traffic is mostly server to client (notifications, progress, a live feed) and WebSocket when the client also sends a steady stream of messages or when you need binary frames.

Table 2. WebSocket compared with Server-Sent Events
Aspect WebSocket Server-Sent Events

Direction

Bidirectional (full duplex)

Server to client only; the client uses ordinary requests for anything it must send

Transport

An HTTP/1.1 Upgrade handshake, then its own framing over the same TCP connection (ws://, wss://)

A normal HTTP response with Content-Type: text/event-stream that never ends

Payload

Text or binary frames

UTF-8 text lines only; send JSON as text

Reconnection

Not built in: you write it

Built into the browser EventSource, including the Last-Event-ID header for resuming

Infrastructure

Needs proxies and load balancers that pass Upgrade; no HTTP caching or middleware

Plain HTTP: works with existing authentication, cookies, compression and HTTP/2 multiplexing

Node.js support

Client built in; server needs ws (or similar) or hand-written framing

Server is just node:http; client via fetch streaming or the experimental EventSource

The built-in WebSocket client

new WebSocket(url) is the browser API: it returns immediately, connects in the background and reports everything through events ('open', 'message', 'error', 'close'), either with addEventListener() or the onmessage-style properties. send() takes a string, an ArrayBuffer or a typed array, close(code, reason) starts the closing handshake, and readyState tells you where the connection is (CONNECTING, OPEN, CLOSING, CLOSED). For binary frames, binaryType chooses whether event.data arrives as a Blob (the default) or an ArrayBuffer. The Node.js Learn article gives the same recipe.

A WebSocket client with no dependency (client.mjs)
// Built-in WebSocket client (global, no import, no dependency).
const url = process.argv[2];
const socket = new WebSocket(url);

socket.addEventListener('open', () => {
  console.log('open, subprotocol:', JSON.stringify(socket.protocol));
  socket.send(JSON.stringify({ type: 'hello', from: 'client' }));
});

socket.addEventListener('message', (event) => {
  // event.data is a string for text frames; binaryType controls binary frames.
  console.log('message:', event.data);
  socket.close(1000, 'done');
});

socket.addEventListener('close', (event) => {
  console.log('close:', event.code, event.reason, 'clean =', event.wasClean);
});

socket.addEventListener('error', () => {
  console.error('error (the event carries no details; see the close code)');
});

Run against an echo server (the ws one in the next section), it prints:

Output of client.mjs
open, subprotocol: ""
message: {"type":"hello","from":"client"}
close: 1000 done clean = true

A few behaviours matter in practice.

  • Errors are opaque. The 'error' event carries no reason, by design of the web API; the details that help are the close code and reason in the 'close' event (1000 is a normal closure, 1006 means the connection dropped without a close frame).

  • No automatic reconnection and no heartbeat. If the server restarts, the 'close' event fires and nothing else happens. Reconnect with exponential backoff and jitter in your own code, and re-subscribe after reconnecting.

  • No backpressure. The WebSocket API cannot slow the sender down: when messages arrive faster than you process them they pile up in memory. Check bufferedAmount before sending large volumes, and keep message handlers fast (or hand heavy work to a worker thread).

  • Request headers. The standard constructor accepts only subprotocols, but the Undici-based implementation in Node.js also accepted a headers option in the examples of this page (observed on 26, 24 and 22, an Undici extension that browsers do not have). That is how a non-browser client can send an Authorization header; a browser client has to authenticate with a cookie, a subprotocol or a short-lived ticket instead.

  • Turning it off. --no-experimental-websocket removes the global, which is useful in a locked-down environment or to force an explicit dependency; see CLI Options, Environment & Config File.

WebSocket servers

Node.js has no WebSocket server in the standard library, and the Learn article says so explicitly. What it gives you is the building block: the HTTP server’s 'upgrade' event.

The handshake and the 'upgrade' event

A client opens an ordinary HTTP/1.1 request with Upgrade: websocket, Connection: Upgrade, a random Sec-WebSocket-Key and a Sec-WebSocket-Version: 13 header. If the server agrees, it answers 101 Switching Protocols with a Sec-WebSocket-Accept header, which is the Base64 SHA-1 of the client key concatenated with a fixed GUID from RFC 6455. From then on the TCP connection no longer carries HTTP: it carries WebSocket frames.

By default node:http ignores upgrade requests and treats them as normal requests, so your 'request' handler answers them. When you register an 'upgrade' listener, Node.js emits (request, socket, head) for each of them, where socket is the raw duplex stream (a net.Socket, in some cases a plain duplex) and head holds any bytes already read past the headers. You are then responsible for the rest, including refusing the upgrade by writing an HTTP error to the socket and destroying it.

A hand-written upgrade (raw-upgrade.mjs), for understanding only
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';

const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; // fixed by RFC 6455

const server = createServer((req, res) => {
  res.writeHead(426, { Upgrade: 'websocket' }).end('Upgrade required\n');
});

server.on('upgrade', (req, socket) => {
  if (req.headers.upgrade?.toLowerCase() !== 'websocket') {
    socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
    return;
  }
  const accept = createHash('sha1')
    .update(req.headers['sec-websocket-key'] + GUID)
    .digest('base64');
  socket.write(
    'HTTP/1.1 101 Switching Protocols\r\n' +
      'Upgrade: websocket\r\n' +
      'Connection: Upgrade\r\n' +
      `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
  );
  // One unmasked text frame (FIN + opcode 1, payload shorter than 126 bytes),
  // then a close frame (opcode 8, status 1000). Real code should use `ws`.
  const text = Buffer.from('hello from a hand-written server');
  socket.write(Buffer.concat([Buffer.from([0x81, text.length]), text]));
  socket.end(Buffer.from([0x88, 0x02, 0x03, 0xe8]));
});

server.listen(0, '127.0.0.1', () => {
  const { port } = server.address();
  const ws = new WebSocket(`ws://127.0.0.1:${port}`);
  ws.onmessage = (e) => console.log('client got:', e.data);
  ws.onclose = (e) => {
    console.log('client close:', e.code);
    server.close();
  };
});

It prints:

Output of raw-upgrade.mjs
client got: hello from a hand-written server
client close: 1000

This example never reads a client frame, because incoming frames are masked and may be fragmented, and a correct parser also has to handle ping and pong frames, extensions, UTF-8 validation and size limits. That is the reason to use a library for anything real.

Using the ws library

ws is the de facto WebSocket implementation for Node.js. Install it with npm install ws. new WebSocketServer({ port }) is the quick start; for a real application use noServer: true, which lets your own 'upgrade' handler apply routing and authentication before any WebSocket exists, and then call handleUpgrade() for the requests you accept. The ws documentation discourages its verifyClient option in favour of exactly this pattern.

A ws server with authentication in the upgrade handler (ws-server.mjs)
import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';

const TOKEN = process.env.FEED_TOKEN ?? 'dev-only-token'; // from --env-file in real life
const ALLOWED_ORIGINS = new Set(['https://bookshelf.example']);

const server = createServer((req, res) => res.writeHead(404).end());
// noServer: we decide, in the 'upgrade' handler, which requests may upgrade.
const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 });

server.on('upgrade', (req, socket, head) => {
  const { pathname } = new URL(req.url, 'http://placeholder');
  const origin = req.headers.origin;
  const reject = (status, text) => {
    socket.write(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\n\r\n`);
    socket.destroy();
  };
  if (pathname !== '/ws/reviews') return reject(404, 'Not Found');
  // Browsers always send Origin; non-browser clients may omit it.
  if (origin && !ALLOWED_ORIGINS.has(origin)) return reject(403, 'Forbidden');
  if (req.headers.authorization !== `Bearer ${TOKEN}`) {
    return reject(401, 'Unauthorized');
  }
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
});

// Heartbeat: ping every client, terminate those that never answered the last ping.
const alive = new WeakSet();
wss.on('connection', (ws) => {
  alive.add(ws);
  ws.on('pong', () => alive.add(ws));
  ws.on('error', console.error);
});
const heartbeat = setInterval(() => {
  for (const ws of wss.clients) {
    if (!alive.has(ws)) { ws.terminate(); continue; }
    alive.delete(ws);
    ws.ping();
  }
}, 30_000);
heartbeat.unref();

export function broadcast(message) {
  const json = JSON.stringify(message);
  for (const ws of wss.clients) {
    if (ws.readyState === ws.OPEN) ws.send(json);
  }
}

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const open = (headers) => new Promise((resolve) => {
    const c = new WebSocket(`ws://127.0.0.1:${port}/ws/reviews`, { headers });
    c.onopen = () => resolve(c);
    c.onerror = () => resolve(null);
  });
  console.log('no token  ->', (await open({})) ? 'connected' : 'refused');
  const client = await open({ authorization: `Bearer ${TOKEN}` });
  console.log('with token->', client ? 'connected' : 'refused');
  client.onmessage = (e) => {
    console.log('received', e.data);
    client.close();
    wss.close();
    server.close();
  };
  broadcast({ type: 'review', bookId: 7, stars: 5 });
});

The program tries to connect twice and prints:

Output of ws-server.mjs
no token  -> refused
with token-> connected
received {"type":"review","bookId":7,"stars":5}

The settings worth knowing:

Table 3. Options and behaviours of ws that matter in production
Item What to know

maxPayload

The largest accepted message in bytes. The default is 100 MiB, which is far too generous for a feed of small JSON messages; set it explicitly (the example uses 64 KiB).

perMessageDeflate

Compression of messages. It is off by default on the server (and on by default on the ws client). Enabling it costs CPU and memory per connection and has a history of memory-fragmentation problems under load, so measure before turning it on.

wss.clients

A Set of the connected clients, present when the clientTracking option is truthy (it was in the examples of this page), used for broadcasts. Check readyState === OPEN before sending.

Heartbeat

A TCP connection can die silently (a phone leaving coverage, a NAT timing out). Send ws.ping() on an interval, mark the socket alive on 'pong' and terminate() the ones that did not answer, as the example does. Browsers answer pings automatically.

bufferedAmount

Bytes queued by send() but not yet written. Skip or drop slow consumers instead of letting it grow without bound.

Origin check

Browsers do not apply the same-origin policy to WebSocket handshakes, so a page on any site can open a socket to your server using the visitor’s cookies. Validate the Origin header in the upgrade handler (cross-site WebSocket hijacking) and do not rely on cookies alone.

TLS

Use wss://. Terminate TLS in the HTTPS server or the reverse proxy that forwards the Upgrade header; see HTTPS, TLS & HTTP/2.

Socket.IO

Socket.IO is a separate library that offers rooms, acknowledgements, automatic reconnection and fallbacks on top of its own protocol. It is not a WebSocket implementation: a plain WebSocket client cannot talk to a Socket.IO server, and the reverse. Reach for it when you want those conveniences and control both ends; use ws or SSE when you want the standard protocol. Its Redis adapter solves the multi-process problem of the last section of this page for Socket.IO applications. See the Socket.IO site for its documentation; it is not covered further here.

Server-Sent Events with node:http

The Bookshelf "new review" feed is a one-way notification, so SSE is the natural fit. The format is defined by the WHATWG HTML Standard: the server answers with Content-Type: text/event-stream, keeps the response open and writes events separated by a blank line. Each event is made of field: value lines.

Table 4. The fields of an event stream
Field Meaning

data:

The payload. Several data: lines in one event are joined with newlines, which is why one JSON document per event, on one line, is the simple choice.

event:

The event name; the browser dispatches it to listeners registered with addEventListener('review', …​). Without it the event is a plain message.

id:

Sets the last event ID. After a dropped connection the browser reconnects and sends it back in the Last-Event-ID request header, so the server can resume.

retry:

The reconnection delay the client should use, in milliseconds.

a line starting with :

A comment, ignored by clients. Sent periodically as a keep-alive so that proxies and load balancers do not close an idle connection.

On the server this is a handler that writes headers, registers a listener on an EventEmitter and, crucially, removes it when the client goes away. The response’s 'close' event fires when the connection ends, whichever side ended it; forgetting that cleanup is a memory leak that grows with every visitor.

An SSE endpoint for the Bookshelf review feed (sse-server.mjs)
import { createServer } from 'node:http';
import { EventEmitter } from 'node:events';

const reviews = new EventEmitter();
const recent = []; // small replay buffer: [{ id, review }]
let nextId = 1;

export function publishReview(review) {
  const entry = { id: nextId++, review };
  recent.push(entry);
  if (recent.length > 100) recent.shift();
  reviews.emit('review', entry);
}

const frame = ({ id, review }) =>
  `id: ${id}\nevent: review\ndata: ${JSON.stringify(review)}\n\n`;

export const server = createServer((req, res) => {
  const url = new URL(req.url, 'http://placeholder');
  if (req.method !== 'GET' || url.pathname !== '/reviews/stream') {
    res.writeHead(404).end();
    return;
  }
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    // Connection: keep-alive is implied on HTTP/1.1; never send it on HTTP/2.
    'X-Accel-Buffering': 'no', // tell nginx not to buffer this response
  });
  res.write('retry: 3000\n\n'); // reconnection delay hint, in milliseconds

  // Replay what the client missed (the browser sends Last-Event-ID on reconnect).
  const lastId = Number(req.headers['last-event-id'] ?? 0);
  for (const entry of recent) if (entry.id > lastId) res.write(frame(entry));

  const onReview = (entry) => res.write(frame(entry));
  reviews.on('review', onReview);
  // A comment line keeps proxies from closing an idle connection.
  const keepAlive = setInterval(() => res.write(': keep-alive\n\n'), 15_000);

  res.on('close', () => { // fires when the client goes away
    clearInterval(keepAlive);
    reviews.off('review', onReview);
  });
});

if (import.meta.main) {
  server.listen(0, '127.0.0.1', () => {
    const { port } = server.address();
    console.log(`SSE on http://127.0.0.1:${port}/reviews/stream`);
  });
}

Two details are easy to miss. res.writeHead() followed by the first res.write() sends the headers immediately, so the client sees the response start; res.flushHeaders() does the same without a body. And a proxy such as nginx may buffer the response until it is complete, which defeats the purpose; the X-Accel-Buffering: no header is nginx’s per-response switch, and other proxies have their own setting. The Connection: keep-alive header is implied on HTTP/1.1 and forbidden on HTTP/2, so do not set it by hand. Over HTTP/2 (see HTTPS, TLS & HTTP/2) SSE is multiplexed with other requests on one connection.

Consuming the stream

In a browser, new EventSource(url) does everything: parsing, reconnecting with Last-Event-ID, dispatching by event name. Node.js has an EventSource global, but it is Stability 1 (Experimental) and absent unless you start Node.js with --experimental-eventsource; it also prints an experimental warning. Without the flag the same code throws ReferenceError: EventSource is not defined. A stable alternative that works today is to read the response body of fetch() as a stream, which is also what you need when the endpoint requires a POST or an Authorization header (the browser EventSource supports neither).

Reading the feed with fetch and web streams (sse-client.mjs)
import { server, publishReview } from './sse-server.mjs';

publishReview({ bookId: 1, stars: 4, text: 'published before anyone listened' });

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const ac = new AbortController();
  const res = await fetch(`http://127.0.0.1:${port}/reviews/stream`, {
    headers: { 'Last-Event-ID': '0' },
    signal: ac.signal,
  });
  console.log(res.status, res.headers.get('content-type'));
  setTimeout(() => publishReview({ bookId: 2, stars: 5, text: 'live' }), 100);

  // A tiny parser: split the byte stream into blank-line-separated events.
  let buffer = '';
  let events = 0;
  try {
    for await (const chunk of res.body.pipeThrough(new TextDecoderStream())) {
      buffer += chunk;
      let end;
      while ((end = buffer.indexOf('\n\n')) !== -1) {
        console.log(JSON.stringify(buffer.slice(0, end)));
        buffer = buffer.slice(end + 2);
        if (++events === 3) ac.abort();
      }
    }
  } catch (err) {
    console.log('stopped:', err.name);
  }
  server.close();
  server.closeAllConnections();
});

It prints (the keep-alive comments are not sent within this short run):

Output of sse-client.mjs
200 text/event-stream
"retry: 3000"
"id: 1\nevent: review\ndata: {\"bookId\":1,\"stars\":4,\"text\":\"published before anyone listened\"}"
"id: 2\nevent: review\ndata: {\"bookId\":2,\"stars\":5,\"text\":\"live\"}"
stopped: AbortError

The parser above is deliberately minimal: it keeps whole events as strings and does not implement the data: joining, comments or retry. With the experimental client enabled the same feed is this short:

The experimental EventSource, run with node --experimental-eventsource (es-client.mjs)
import { server, publishReview } from './sse-server.mjs';

publishReview({ bookId: 1, stars: 4 });
server.listen(0, '127.0.0.1', () => {
  const { port } = server.address();
  const source = new EventSource(`http://127.0.0.1:${port}/reviews/stream`);
  source.addEventListener('review', (event) => {
    console.log(event.lastEventId, event.data);
    source.close(); // without close() the client reconnects forever
    server.close();
    server.closeAllConnections();
  });
});

It prints 1 {"bookId":1,"stars":4} after the experimental warning. Without source.close() the client would reconnect forever.

Scaling fan-out across threads and processes

Everything above keeps its connections in memory of one process. As soon as you run more than one (worker threads, a cluster, several containers behind a load balancer) a review published in process A is invisible to the clients connected to process B. The fix is the same for WebSocket and SSE: do not write to local connections at the place where the event happens, publish the event to a channel that every process listens to, and let each process write it to its own connections.

BroadcastChannel: between threads of one process

BroadcastChannel is the web API for one-to-many messaging between contexts that share a channel name. In Node.js it connects the main thread and the worker_threads workers of a single process; it does not cross process or machine boundaries. It is the right tool when a worker pool holds connections, or when workers must tell each other to invalidate a cache. A message is delivered to every other BroadcastChannel object with the same name, not to the sender, and an open channel keeps its thread alive until you call close().

A message reaching every listening thread (bc.mjs)
import {
  BroadcastChannel, Worker, isMainThread, parentPort, threadId,
} from 'node:worker_threads';

if (isMainThread) {
  const bc = new BroadcastChannel('reviews'); // same name = same channel
  const workers = [new Worker(import.meta.filename), new Worker(import.meta.filename)];
  let ready = 0;
  for (const w of workers) {
    w.on('message', () => { // each worker says "listening" once
      if (++ready === workers.length) bc.postMessage({ bookId: 7, stars: 5 });
    });
    w.on('exit', () => { if (--ready === 0) bc.close(); });
  }
} else {
  const bc = new BroadcastChannel('reviews');
  bc.onmessage = (event) => {
    console.log(`thread ${threadId} received`, event.data);
    bc.close(); // an open channel keeps the thread alive
    parentPort.close();
  };
  parentPort.postMessage('listening');
}

It prints one line per worker (the order varies between runs):

Output of bc.mjs
thread 2 received { bookId: 7, stars: 5 }
thread 1 received { bookId: 7, stars: 5 }

Redis Pub/Sub: between processes and machines

For several processes or hosts you need a broker. Redis Pub/Sub is the simplest: publishers send a message to a named channel, and every subscriber connected at that moment receives it. With node-redis, a client that has subscribed can issue no other commands, so you duplicate() the connection and keep one for publishing and one for subscribing. The example runs two "instances" of the Bookshelf server in one script, both subscribed to the same channel. A review posted to instance A reaches the SSE client connected to instance B. Note that the POST handler publishes to Redis and does not write to local streams: A receives its own message through its subscription like everybody else, so there is a single code path.

Fan-out across instances through Redis Pub/Sub (fanout.mjs)
import { createServer } from 'node:http';
import { createClient } from 'redis';

const CHANNEL = 'bookshelf:reviews';

// One "instance" of the app: it would normally be one process or container.
async function startInstance(redisUrl) {
  const publisher = createClient({ url: redisUrl });
  const subscriber = publisher.duplicate(); // a subscribed connection can do nothing else
  publisher.on('error', console.error);
  subscriber.on('error', console.error);
  await Promise.all([publisher.connect(), subscriber.connect()]);

  const streams = new Set(); // SSE responses connected to THIS instance
  await subscriber.subscribe(CHANNEL, (message) => {
    for (const res of streams) res.write(`event: review\ndata: ${message}\n\n`);
  });

  const server = createServer(async (req, res) => {
    if (req.method === 'GET' && req.url === '/reviews/stream') {
      res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
      res.write(': connected\n\n');
      streams.add(res);
      res.on('close', () => streams.delete(res));
    } else if (req.method === 'POST' && req.url === '/reviews') {
      let body = '';
      for await (const chunk of req.setEncoding('utf8')) body += chunk;
      // Publish to Redis, not to local clients: every instance (this one too)
      // receives the message through its subscription and fans it out.
      await publisher.publish(CHANNEL, JSON.stringify(JSON.parse(body)));
      res.writeHead(202).end();
    } else {
      res.writeHead(404).end();
    }
  });
  await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
  const close = async () => {
    for (const res of streams) res.end();
    server.close();
    await Promise.all([subscriber.close(), publisher.close()]);
  };
  return { port: server.address().port, close };
}

const redisUrl = process.env.REDIS_URL ?? 'redis://127.0.0.1:6379';
const [a, b] = [await startInstance(redisUrl), await startInstance(redisUrl)];

// A client listens on instance B...
const stream = await fetch(`http://127.0.0.1:${b.port}/reviews/stream`);
const reader = stream.body.pipeThrough(new TextDecoderStream()).getReader();
await reader.read(); // ": connected"

// ...and a review is posted to instance A.
await fetch(`http://127.0.0.1:${a.port}/reviews`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ bookId: 7, stars: 5 }),
});
console.log('instance B client got:', JSON.stringify((await reader.read()).value));

await reader.cancel();
await Promise.all([a.close(), b.close()]);

With REDIS_URL pointing at a Redis server it prints:

Output of fanout.mjs
instance B client got: "event: review\ndata: {\"bookId\":7,\"stars\":5}\n\n"

The same structure works for WebSocket: replace the SSE res.write() in the subscription callback with a loop over wss.clients.

Redis Pub/Sub has semantics you must design around.

Table 5. What Redis Pub/Sub does and does not give you
Property Consequence

At-most-once delivery

A message is delivered to the subscribers connected at publish time and then forgotten. A subscriber that was disconnected misses it; nothing is stored or retried.

No history

There is no equivalent of Last-Event-ID replay. If the feed must survive reconnects, keep the events somewhere durable (a database table, or Redis Streams, which persist messages and support consumer groups) and replay from there, as the in-memory buffer of the SSE example does for a single process.

Not a job queue

A message goes to all subscribers, not to one of them. For work that must be done once, use a queue (see Background Jobs, Scheduling & Queues).

One subscription connection per process

Do not open one per client; keep one subscriber per process and fan out in memory, as the example does with streams.

Failure of Redis

Messages published while Redis is down, or while a subscriber is disconnected, are lost. The real-time feed degrades; the system of record must not depend on it.

Cluster mode

Redis 7 added sharded Pub/Sub (SPUBLISH, SSUBSCRIBE), which restricts the propagation of messages to one shard of a cluster in order to scale it.

Two further points apply to long-lived connections in a scaled-out deployment. First, a WebSocket stays on the process that accepted it, so a load balancer only needs sticky routing when a protocol keeps per-connection state on the server (Socket.IO’s HTTP long-polling fallback is the classic case); plain WebSocket and SSE do not need it once the shared state lives outside the process. See Cluster & Scaling. Second, stateless connection handling makes deployments easier: on a rolling restart, clients reconnect (SSE does it by itself, a WebSocket client must), and graceful shutdown should close the connections you track instead of waiting for them to end, since server.close() waits for open connections.

What changed recently

Table 6. Changes affecting real-time code, with versions
Change Detail Version

Built-in WebSocket client

An Undici-based global WebSocket replaces the need for ws as a client; it was behind a flag first and is stable since 22.4.0.

v21.0.0 (v20.10.0), flag removed v22.0.0, stable v22.4.0

Experimental EventSource

An SSE client global exists behind --experimental-eventsource; it is still Stability 1.

v22.3.0 (v20.18.0), still experimental in v26

shouldUpgradeCallback

http.createServer() can decide per request whether an Upgrade triggers the 'upgrade' event, and an accepted upgrade with no listener now destroys the socket instead of being left open.

v24.9.0 and v22.21.0

Upgrade request bodies

The stream argument of 'upgrade' no longer exposes the request body raw; a body is parsed normally and the stream carries socket content only after it.

v26.0.0

import.meta.main

Lets a module file start a server only when run directly, as sse-server.mjs does, instead of the require.main === module test.

v24.2.0 (v22.18.0)

Stable fetch

Streaming response bodies are the dependency-free way to consume SSE today (see Fetch & HTTP Clients).

stable since v21

Where the books are outdated

Both O’Reilly books predate the built-in WebSocket client or give real-time little room, so this page has to fill the gap from the official documentation.

  • Efficient Node.js (Samer Buna) mentions real-time frameworks such as Socket.IO only in passing, within its overview of the ecosystem, and has no coverage of the 'upgrade' event, the ws library or SSE. It also predates the global WebSocket client (stable since v22.4.0) and the stable global fetch that makes stream-based SSE consumption possible without a package.

  • Node.js Projects (Jonathan Wexler) uses Redis Pub/Sub for the messaging part of a coffee-orders application and points to WebSockets and Server-Sent Events as follow-up topics. Pub/Sub is at-most-once and broadcasts to every subscriber, so it cannot replace a durable queue the way the project’s flow suggests; use a queue or Redis Streams for work that must not be lost, as described above. Check any Redis client code from older tutorials against the current node-redis documentation (createClient(), connect(), duplicate(), subscribe() with a callback, close()).

Common pitfalls

Table 7. Pitfalls with real-time connections
Pitfall Fix

Not cleaning up listeners on disconnect

Remove the emitter listener and clear the keep-alive timer in the response’s 'close' handler. Count connections in a metric to notice leaks.

Trusting a WebSocket because it has a cookie

Check Origin and authenticate in the 'upgrade' handler; browsers do not enforce the same-origin policy for WebSockets.

Default maxPayload of 100 MiB

Set a limit that matches your messages and validate the JSON you parse.

No heartbeat

Ping on an interval and terminate sockets that miss a pong; send an SSE comment line every 15-30 seconds.

Broadcasting in a loop without checking readyState or bufferedAmount

Skip sockets that are not open, and drop or disconnect slow clients.

Publishing to local connections only

Publish to the shared channel and write to local connections from the subscription, so every instance behaves the same.

Putting a secret in the WebSocket URL

Query strings end up in access logs. Prefer an Authorization header (non-browser clients), a cookie, or a one-time ticket obtained over HTTPS and valid for seconds.

Assuming server.close() ends the connections

It stops accepting new ones and waits for the open ones. Track them and end them on shutdown, then closeAllConnections() after a grace period.

References