Benchmarking Node.js WebSocket servers #
Someone on the team shares a chart showing that uWebSockets.js handles ten times more messages per second than ws, and proposes a rewrite. Someone else finds a benchmark where Socket.IO is barely slower than raw WebSockets. Both charts are real, and neither says much about your system, because they measured a different workload — usually an echo server with tiny messages and no application logic — on hardware and settings that do not match yours. Library choice does matter for real-time servers, especially for memory per connection and broadcast throughput, but the only benchmark worth acting on is one that reproduces your workload’s shape and measures the resource that actually limits your fleet.
Root cause #
Published WebSocket benchmarks usually measure echo throughput: clients send a message, the server sends it back, repeat as fast as possible. That exercises parsing and the send path under ideal conditions. Production real-time servers are rarely limited by echo throughput. They are limited by one of three other things: memory per idle connection (how many connections fit on a node), broadcast fan-out (how fast one message reaches thousands of sockets), or tail latency under mixed load (how long a message waits when the event loop is busy with application work). A library can win the first metric and lose the others.
Benchmarks also go wrong in the harness. Running the load generator on the same machine as the server makes them compete for CPU. Leaving permessage-deflate on in one library and off in another compares compression, not libraries. And measuring with a load generator that cannot keep up produces numbers that describe the generator.
Resolution #
Build a small harness that runs each candidate server with the same application behaviour and settings, and measures the three production-relevant metrics. The server side stays minimal but realistic: join a room on connect, broadcast to the room on message, and optionally spend a fixed amount of CPU per message to stand in for application logic.
// bench-server-ws.ts — the `ws` candidate. Mirror the same behaviour for each library.
import { WebSocketServer, WebSocket } from 'ws';
const PORT = Number(process.env.PORT ?? 9001);
const APP_WORK_US = Number(process.env.APP_WORK_US ?? 50); // simulated handler cost per message
const rooms = new Map<string, Set<WebSocket>>();
function burn(us: number) { const end = process.hrtime.bigint() + BigInt(us * 1000); while (process.hrtime.bigint() < end); }
const wss = new WebSocketServer({ port: PORT, perMessageDeflate: false, maxPayload: 64 * 1024 });
wss.on('connection', (ws, req) => {
const room = new URL(req.url!, 'http://x').searchParams.get('room') ?? 'r0';
let set = rooms.get(room);
if (!set) rooms.set(room, (set = new Set()));
set.add(ws);
ws.on('message', (data) => {
burn(APP_WORK_US); // same "application" cost everywhere
for (const peer of set!) if (peer.readyState === WebSocket.OPEN) peer.send(data);
});
ws.on('close', () => set!.delete(ws));
});
// Report memory once a second for the harness to scrape.
setInterval(() => {
const m = process.memoryUsage();
process.stdout.write(JSON.stringify({ t: Date.now(), conns: wss.clients.size, rss: m.rss, heap: m.heapUsed }) + '\n');
}, 1_000);
The uWebSockets.js candidate implements the same rooms with its built-in topics (ws.subscribe(room) and app.publish(room, msg)), which is its idiomatic — and fastest — fan-out path; Socket.IO uses socket.join(room) and io.to(room).emit(...). Benchmark each library the way you would actually use it, not a lowest-common-denominator version.
Then run three measurements, each against a fresh server process, with the load generator on a separate machine in the same network:
- Idle density. Open N connections that send nothing (beyond heartbeats), wait for memory to stabilise, record RSS per connection. Repeat at two or three values of N to confirm linearity — the method in measuring memory per WebSocket connection.
- Fan-out rate. Put 1,000 or 5,000 clients in one room, publish at increasing rates, and record deliveries per second at which the p99 publish-to-receive latency crosses your budget.
- Mixed-load tail latency. Hold your target connection count across many small rooms, publish at your expected rate with
APP_WORK_USset to your measured handler cost, and record p50/p99 delivery latency and event-loop lag.
A k6 or Artillery script from load testing WebSockets with k6 drives measurements 2 and 3 with the delivery-latency metric already defined.
Edge cases #
Compression. permessage-deflate changes memory and CPU profiles dramatically. Benchmark with the setting you will use in production, identical across candidates.
Worker threads and clustering. Some libraries are usually deployed with multiple processes per machine (Node cluster or separate processes behind a load balancer). Compare per-core or per-machine results in the deployment shape you will actually run.
GC and warm-up. Discard the first minutes of each run and report steady-state numbers; V8 optimises hot paths and the heap settles over time. Run each measurement several times and report the spread, not the best run.
Verification #
Trust a benchmark only when it passes three checks. The generator is not the bottleneck: its CPU stays below about 70%, and adding a second generator does not change the result. Runs are reproducible: repeated runs agree within a few percent. The result predicts production: take the density and fan-out figures, predict a staging fleet’s capacity for your real workload, then load-test staging and compare. A benchmark that fails the third check measured something your system does not do.
# Server-side resource trace during a run (one line per second from the harness).
node bench-server-ws.js | tee ws-run.jsonl &
# Summarise RSS per connection at steady state.
tail -n 60 ws-run.jsonl | jq -s 'map(.rss / (.conns + 0.0001)) | add / length / 1024 | floor'
Operational checklist #
FAQ #
Is uWebSockets.js faster than ws? #
In most published benchmarks and typical measurements, yes — particularly in memory per connection and raw fan-out, because it is implemented in C++ with native pub/sub. Whether that matters depends on whether those are your bottlenecks; with heavy application logic per message, the gap in end-to-end latency narrows.
How much slower is Socket.IO than raw WebSockets? #
Socket.IO adds packet encoding, its own heartbeats and features such as rooms and acknowledgements, which cost some memory and CPU per connection and message. For many applications the difference is acceptable; for very dense or high-fan-out servers it can be significant. Measure with your workload.
What’s the most important WebSocket server metric? #
Usually connections per gigabyte of memory, because it sets how many nodes you need; for broadcast-heavy products, deliveries per second at your latency budget. Echo throughput is rarely the limiting factor.
Should I rewrite my server for a faster library? #
Only if a benchmark of your workload shows the library is the bottleneck and the savings — fewer nodes, lower latency — exceed the cost of the rewrite and of losing features you rely on. Often, fixing application-level fan-out and serialization gives more.
Related #
- Measuring Memory per WebSocket Connection — the density measurement in detail.
- Load Testing WebSockets with k6 — the driver for fan-out and latency runs.
- Socket.IO vs Raw WebSockets — features versus overhead.
- Capacity Planning for WebSocket Fleets — turning benchmark numbers into node counts.
Back to Load Testing & Capacity Planning.