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
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
Global |
No stability label in the docs (stable) |
Added in v21.0.0 and v20.10.0 behind |
|
Stable |
Added in v0.1.94. Since v24.9.0 and v22.21.0 the |
|
1 Experimental |
Added in v22.3.0 and v20.18.0. Only exists with |
|
Stable |
Added in v15.4.0, no longer experimental in v18.0.0. Reaches threads of the same process only. |
|
Third party |
8.22.0 was the latest on 2026-10-03; its documentation lives in the project repository. |
|
Third party |
6.3.0 was the latest on 2026-10-03; used here for Pub/Sub only. |
Source: Node.js API: Globals, WebSocket
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.
| 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 |
A normal HTTP response with |
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 |
Infrastructure |
Needs proxies and load balancers that pass |
Plain HTTP: works with existing authentication, cookies, compression and HTTP/2 multiplexing |
Node.js support |
Client built in; server needs |
Server is just |
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.
// 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:
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 closecodeandreasonin 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
bufferedAmountbefore 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
headersoption 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 anAuthorizationheader; a browser client has to authenticate with a cookie, a subprotocol or a short-lived ticket instead. -
Turning it off.
--no-experimental-websocketremoves 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.
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:
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.
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:
no token -> refused
with token-> connected
received {"type":"review","bookId":7,"stars":5}
The settings worth knowing:
| Item | What to know |
|---|---|
|
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). |
|
Compression of messages. It is off by default on the server (and on by default on the |
|
A |
Heartbeat |
A TCP connection can die silently (a phone leaving coverage, a NAT timing out). Send |
|
Bytes queued by |
|
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 |
TLS |
Use |
Source: ws documentation: API
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.
| Field | Meaning |
|---|---|
|
The payload. Several |
|
The event name; the browser dispatches it to listeners registered with |
|
Sets the last event ID. After a dropped connection the browser reconnects and sends it back in the |
|
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.
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).
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):
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
Source: Node.js API: Globals, fetch
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:
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().
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):
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.
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:
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.
| 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 |
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 |
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 ( |
Source: Redis documentation: Pub/sub
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
| Change | Detail | Version |
|---|---|---|
Built-in WebSocket client |
An Undici-based global |
v21.0.0 (v20.10.0), flag removed v22.0.0, stable v22.4.0 |
Experimental EventSource |
An SSE client global exists behind |
v22.3.0 (v20.18.0), still experimental in v26 |
|
|
v24.9.0 and v22.21.0 |
Upgrade request bodies |
The |
v26.0.0 |
|
Lets a module file start a server only when run directly, as |
v24.2.0 (v22.18.0) |
Stable |
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, thewslibrary or SSE. It also predates the globalWebSocketclient (stable since v22.4.0) and the stable globalfetchthat 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
| Pitfall | Fix |
|---|---|
Not cleaning up listeners on disconnect |
Remove the emitter listener and clear the keep-alive timer in the response’s |
Trusting a WebSocket because it has a cookie |
Check |
Default |
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 |
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 |
Assuming |
It stops accepting new ones and waits for the open ones. Track them and end them on shutdown, then |
References
-
Node.js Learn: WebSocket client with Node.js, https://nodejs.org/learn/getting-started/websocket
-
Node.js API: Globals, WebSocket, https://nodejs.org/docs/latest/api/globals.html#class-websocket
-
Node.js API: Globals, EventSource, https://nodejs.org/docs/latest/api/globals.html#class-eventsource
-
Node.js API: Globals, fetch, https://nodejs.org/docs/latest/api/globals.html#fetch
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: HTTP, Event: 'upgrade', https://nodejs.org/docs/latest/api/http.html#event-upgrade
-
Node.js API: HTTP, response.flushHeaders(), https://nodejs.org/docs/latest/api/http.html#responseflushheaders
-
Node.js API: Worker threads, Class: BroadcastChannel, https://nodejs.org/docs/latest/api/worker_threads.html#class-broadcastchannel-extends-eventtarget
-
Node.js API: Events, https://nodejs.org/docs/latest/api/events.html
-
ws, WebSocket library for Node.js, https://github.com/websockets/ws
-
ws, API documentation, https://github.com/websockets/ws/blob/master/doc/ws.md
-
Socket.IO, https://socket.io/
-
Socket.IO, Redis adapter, https://socket.io/docs/v4/redis-adapter/
-
Socket.IO, Using multiple nodes, https://socket.io/docs/v4/using-multiple-nodes/
-
Redis: Pub/sub, https://redis.io/docs/latest/develop/pubsub/
-
Redis: Streams, https://redis.io/docs/latest/develop/data-types/streams/
-
Redis: Node.js client, https://redis.io/docs/latest/develop/clients/nodejs/
-
node-redis: Pub/Sub, https://github.com/redis/node-redis/blob/master/docs/pub-sub.md
-
IETF: RFC 6455, The WebSocket Protocol, https://datatracker.ietf.org/doc/html/rfc6455
-
WHATWG: HTML Standard, Server-sent events, https://html.spec.whatwg.org/multipage/server-sent-events.html
-
WHATWG: WebSockets Standard, https://websockets.spec.whatwg.org/
-
MDN: Using server-sent events, https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
-
MDN: WebSocket, https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187/
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/