WebSocket in Node.js: Server, Client, Heartbeat and Reconnect

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.

Which piece does what
JobUseWhere it comes from
Accept connectionsnew WebSocketServer({ port })ws package (npm install ws)
Connect from Nodenew WebSocket(url)Global in Node 22 and later
Connect from a browsernew WebSocket(url)Built into every modern browser
Find dead socketsws.ping() plus a pong listenerws package
Survive restartsReconnect with exponential backoffYour 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.

Reconnect delay calculator

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.

Step 1: a server with ws

server.js: echo and broadcast

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");
});

Step 2: a client with no dependencies

client.js: Node's global WebSocket

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));

Step 3: drop dead connections with ping and pong

Add to server.js

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));

Step 4: a client that reconnects

reconnect.js: exponential backoff with jitter

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");

Behind nginx

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.

nginx location for the socket

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;
}

How a connection starts

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.

Heartbeats in plain words

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.

Reconnecting without hammering the server

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.

How it works

  1. Run npm install ws in a project whose package.json has "type": "module", so the import syntax in the examples works.
  2. Start the server file with node server.js. It listens on port 8080 and logs each message.
  3. Run the client file in a second terminal with Node 22 or newer, or open the browser console and paste the same code.
  4. Stop the server with Ctrl+C and start it again to watch the reconnecting client wait, retry and recover.

Tips

Frequently asked questions

Does Node.js have WebSocket built in?

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.

Should I use ws or Socket.IO?

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.

Why do connections die without a close event?

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.

Can a browser client send pings?

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.

How do I share a port with an HTTP server?

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.

More in Guides

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.

Product

Who It's For

Features

Resources

Company

View Sitemap