Node.js has no built-in WebSocket server, so most projects install the ws package and create a WebSocketServer. For the client side, Node ships a browser-compatible global WebSocket, added in v21.0.0 and v20.10.0, unflagged in v22.0.0 and marked stable in v22.4.0. Add a ping and pong heartbeat plus reconnect with backoff before going live.
| Job | Use | Where it comes from |
|---|---|---|
| Accept connections | new WebSocketServer({ port }) | ws package (npm install ws) |
| Connect from Node | new WebSocket(url) | Global in Node 22 and later |
| Connect from a browser | new WebSocket(url) | Built into every modern browser |
| Find dead sockets | ws.ping() plus a pong listener | ws package |
| Survive restarts | Reconnect with exponential backoff | Your own client code |
Updated
This guide builds one small chat-style server and the clients that talk to it, in order: a server that echoes and broadcasts, a Node script that connects without any dependency, a heartbeat that throws away connections that silently died, and a client that comes back after the server restarts. The calculator below shows how your retry delays grow before you put them in code.
Set a first delay, a multiplier and a ceiling to see how long a client waits before each retry, and the shortest and longest wait once jitter is added.
Each message is sent back to every open client. The error listener keeps one bad socket from crashing the process.
import { WebSocketServer, WebSocket } from "ws";
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (socket, req) => {
console.log("client from", req.socket.remoteAddress);
socket.on("error", console.error);
socket.on("message", (data, isBinary) => {
for (const client of wss.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
}
});
socket.send("welcome");
});Runs unchanged on Node 22 or later and in a browser console, because both expose the same WebSocket class.
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => socket.send("hello from node"));
socket.addEventListener("message", (e) => console.log("got:", e.data));
socket.addEventListener("close", (e) => console.log("closed", e.code));Our version of the pattern from the ws README: mark alive on pong, terminate anything that missed the last round.
wss.on("connection", (socket) => {
socket.isAlive = true;
socket.on("pong", () => { socket.isAlive = true; });
});
const timer = setInterval(() => {
for (const socket of wss.clients) {
if (!socket.isAlive) { socket.terminate(); continue; }
socket.isAlive = false;
socket.ping();
}
}, 30_000);
wss.on("close", () => clearInterval(timer));The delay doubles after each failed attempt up to a ceiling, gets a random spread, and resets once a connection opens.
function connect(url, attempt = 0) {
const socket = new WebSocket(url);
socket.addEventListener("open", () => {
attempt = 0;
console.log("connected");
});
socket.addEventListener("close", () => {
const base = Math.min(30_000, 500 * 2 ** attempt);
const wait = base / 2 + Math.random() * (base / 2);
console.log("retrying in", Math.round(wait), "ms");
setTimeout(() => connect(url, attempt + 1), wait);
});
return socket;
}
connect("ws://localhost:8080");nginx only passes the upgrade through when you forward the Upgrade and Connection headers yourself. Before nginx 1.29.7 you also need proxy_http_version 1.1.
proxy_read_timeout raises the 60 second idle limit; the heartbeat above keeps traffic flowing anyway.
location /ws/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
}
A WebSocket begins life as an HTTP GET with Upgrade: websocket. The ws server checks it, answers 101 Switching Protocols and fires its connection event with the new socket and the original request. Anything you want from the handshake, such as a token in the query string or the Origin header, is read from that request.
The protocol says an endpoint that receives a ping must reply with a pong carrying the same data. The server marks each socket as alive when a pong arrives, then on a timer clears the mark and pings again. Any socket still unmarked on the next tick never replied, so the server terminates it.
The ws README uses a 30 second timer for this. Pick a value comfortably below the idle timeout of any proxy in front of you.
A good client waits a little after the first drop, then longer after each failure, up to a ceiling, and resets the counter once a connection opens. Spreading the waits randomly keeps a crowd of clients from returning in lockstep.
Only the client. The global WebSocket class appeared in v21.0.0 and v20.10.0 behind the --experimental-websocket flag, lost the flag in v22.0.0 and stopped being experimental in v22.4.0. There is still no built-in server, which is why ws remains the usual choice.
ws speaks the plain WebSocket protocol, so any standard client, including the browser and Node's global, can connect. Socket.IO adds its own protocol on top, with rooms and automatic reconnection, but needs its own client library on the other end.
If a network path disappears, such as a laptop lid closing, no close frame is ever sent and the server keeps a half-open socket. The heartbeat finds these: a socket that did not answer the last ping is terminated on the next round.
No. The browser API has only send() and close(). Browsers do answer server pings with pongs on their own, which is why the server-side heartbeat works with browser clients too.
Create the HTTP server yourself, for example with Express or node:http, then pass it in as new WebSocketServer({ server }). Upgrade requests go to ws and normal requests go to your app.
Related: WebSocket tester, FastAPI WebSocket guide, Express.js tutorial, nginx load balancing, Install Node and npm on a Mac.
Start for free or see all free tools. We.Inc is free to start, with no card needed. AI building is on paid plans from $20 a month.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.