Skip to content

Performance

Every number on this page comes from a script in bench/, and pnpm bench runs all of them. They were measured on one developer machine (Node v24.14.1, Apple Silicon) — treat them as shapes and ratios, not as a spec sheet, and re-run them on hardware you care about.

bash
pnpm bench

bench/run-all.js discovers every top-level file in bench/, so a new measurement is a new file and nothing else. Nothing in CI runs it: there is no stored baseline, and a benchmark that gates a pipeline on a noisy laptop number is worse than no benchmark.

Against other stacks

bench/rpc-comparison.js puts wrpc's full RPC path (router, validation, context, callback correlation) next to raw WebSocket echoes with no RPC layer at all, and next to two frameworks that do the same job wrpc does.

Stacksmall payload10 KB payloadsmall ×64 batched
wrpc — own WS + RPC dispatch26,43813,587101,219
ws — raw echo, no RPC35,63512,107108,955
uWebSockets.js — raw echo, no RPC30,19612,169158,567
@fastify/websocket — raw echo, no RPC34,88812,307110,184
socket.io — framework RPC via emitWithAck25,25411,12899,472
tRPC — framework RPC over wsLink661¹587¹23,023

ops/sec, higher is better.

¹ tRPC's sequential number is a client-side flush-timer artifact, not throughput: its per-call latency measures a near-constant ~1.5 ms (median 1.53 ms, p10–p90 spread 1.48–1.69 — a fixed delay, not processing), so one awaited call per timer tick caps the sequential rate while the pipelined column shows what the same stack does when many calls share a flush. Compare tRPC on the batched column, where its per-call machinery amortizes.

Read it honestly:

  • A raw echo is the ceiling, not a competitor. ws at 1.35× on small payloads is what a socket costs with no router, no access check and no correlation on top. wrpc paying 26% for all of that is the trade.
  • On real payloads the gap closes and inverts. At 10 KB, wrpc is the fastest entry in the table — including the raw echoes — because the send path avoids re-encoding and re-copying what it already has.
  • Against frameworks doing the same job, wrpc is level with socket.io on a single call and the fastest measured stack at 10 KB. The tRPC rows read through footnote ¹: its sequential number is a client flush-timer artifact, and the honest comparison is the batched column — where wrpc is still ~4.4× ahead. tRPC's type story is excellent and unaffected by any of this.
  • Batching changes the ranking, and a uWebSockets.js-backed stack wins it — which is exactly why the uws engine is a supported swap.

Fan-out

bench/send-path.js — one room, N members, 512-byte payload:

Scenarioratethroughput
sendText 200 B6,877,224/sec1338 MB/s
sendText 4 KB775,007/sec3030 MB/s
room fan-out ×5099,487/sec2751 MB/s
room fan-out ×20029,197/sec3230 MB/s
room fan-out ×50 + deflate2,776/sec9 MB/s

Fan-out encodes the frame once and writes the same buffer to every member; throughput keeps climbing with the member count because the per-recipient cost is a write, not a serialize.

permessage-deflate costs 36× on fan-out

Compression is per-connection by construction — the same payload has to be compressed once per recipient, and the deflate context makes it expensive. sendText 200 B drops from 6.9 M/s to 206 K/s with deflate on. Enable it for bandwidth-bound clients, not for chatty in-datacenter fan-out.

The receive path

bench/parser-throughput.js:

ScenarioMB/smsgs/sec
16 MiB binary message, 16 KiB segments3,084192
200 B text frames ×50K, 64 KiB segments6613,468,803

The large-message number is what SegmentQueue exists for: buffering fragmented reads with O(n) total work instead of re-concatenating a growing buffer on every segment. See wire format.

Batching crosses over at 16

bench/batch-ordering.js compares two ways of restoring order in a batch — a linear scan versus an id-indexed map:

batch sizelinear scanid-indexedwinner
45,285,606/s4,296,103/sscan 1.23×
82,230,358/s2,122,554/sscan 1.05×
16939,014/s1,103,773/sindexed 1.18×
6496,936/s237,578/sindexed 2.45×
12831,160/s121,834/sindexed 3.91×

Which is why the client's default batch.maxSize is 16: the shape changes right there.

Cluster operations

bench/cluster.js, two instances over a MemoryBackplane:

Operationrate
cluster.count()65,797,861/sec
cluster.presence()23,858,163/sec
join + leave with presence deltas449,324/sec
room fan-out ×100 emit18,096/sec
room fan-out ×100 ask, send side7,204/sec

count() and presence() are local map reads by design — that is the whole point of replicating presence instead of requesting it.

Why the code looks the way it does

Two benchmarks exist to justify code shape rather than to advertise a number.

bench/dispatch.js measures the packet-type branch three ways. With the branch cost isolated, a 9-branch if/else if chain is linear (165 M ops/s at the first branch, 68 M at the last) and a switch is flatter (105 M → 69 M). But put a real handler call in the loop and switch and the chain come out level (63.4 vs 68.8 M ops/s) while a table of handler functions loses ~25% (51.7 M ops/s) — V8 compiles a string switch into comparisons it can inline through, and cannot inline through a function value. Object.freeze and Map do not close the gap. So the dispatch branches stay branches.

bench/emitter.js measures the event path that every packet crosses:

Scenarioops/sec
emit fire-and-forget — 1 sync listener64,514,129
emit awaited — 0 listeners15,170,971
emit awaited — 1 sync listener14,128,520
emit awaited — 2 sync listeners3,140,608

The cliff between one and two listeners is the awaited multi-listener path allocating a snapshot so a listener may off() itself mid-emit. Single-listener and zero-listener emits are special-cased around it.

Bundle size is a budget, not a report

bash
pnpm size

Browser-reachable entries carry a budget, and exceeding one fails the run — it is a ratchet, and it runs in CI's lint job. See Browser & bundling for the table and what is in each entry.

Memory, under load

pnpm test:perf streams 1 GiB through a binary stream and fails if RSS grows with it. It is deliberately not in CI — it is slow and memory-sensitive — so run it by hand after touching the stream path.

Released under the MIT License.