Skip to content

Compression ​

Nothing in wRPC compresses anything by default — on any transport, in any direction. That is a choice, not a gap: a deflate per message is CPU spent for every peer to save bytes only some of them need, and the server's first commitment is the cost per frame. This page is the map of where the knobs are, what each one buys, and the two things they share — the codec seam and the router dictionary.

Where the knobs are ​

WireKnobNegotiated byPage
WebSocket, server → client (and both ways from a browser)perMessageDeflate on the engine, contextTakeover, asyncthe upgrade handshake (RFC 7692)performance
WebSocket, client → server from Nodecompression on the client and compression on the serverthe first ping/pongserver
HTTP, packet mode and RESThttp: { compression }, encodings for Brotli / zstd / your ownAccept-Encoding, the server's order (a header past 256 bytes counts as absent)server
Server-Sent Eventssse: { compression }the opening GET's Accept-Encoding, per responseSSE
WebTransportcompression on both endsthe capabilities messageWebTransport
WebRTCcompression on both peersthe description signal (caps) — a raw channel by agreementWebRTC
The broker bindingcompression on both endshello/welcome; a stateless request lists what it acceptsbroker RPC
The rooms backplane, the cluster channelsrooms: { compression }, cluster: { compression }nothing — the marker names the codec; a list makes the rollout losslessscaling

Under session encryption compression happens inside the sealed frame — ciphertext does not compress, so permessage-deflate is switched off for such a connection — and compress-then-encrypt leaks length: send a message that mixes a secret with attacker-influenced data with compress: false.

Every one of them is off until you turn it on, and every one is negotiated where the wire allows it: a lone end is served plain. The protocol reference has the wire forms.

One codec, or a list ​

codec takes one codec or a list in order of preference. Each end announces the ids it holds, and a sender compresses with the first codec of its own list the other end announced:

js
// A Node service that prefers zstd, serving Node clients that may be older
// and browsers whose CompressionStream may not have it:
attachBrokerRpc(server, broker, { compression: { codec: ['zstd', 'deflate-raw'] } });
new WrpcPeer({ router, signaler, compression: { codec: ['zstd', 'brotli', 'deflate-raw'] } });

That makes the list the fallback — a peer without zstd is served deflate instead of plain — and lets the two directions differ: a server answers in zstd a browser that sends deflate, and transport.compression shows both ({ encode: 'zstd', decode: 'deflate-raw' }). A name the platform lacks (zstd before Node 22.15, a format this browser has not) is skipped in a list and refused when it is the only thing asked for. Lists that share nothing leave the wire plain; nothing hangs up.

The backplane negotiates nothing, so there the list means: encode with the head, decode anything on it. A change of codec is then a rollout without a lost envelope — every instance lists both (['deflate-raw', 'zstd']), then the order is swapped (['zstd', 'deflate-raw']), then the old one is dropped. Over a raw WebRTC data channel, which has no handshake either, both ends use the head of their list.

What it costs, what it buys ​

bench/message-compression.js and bench/http-compression.js, one message at a time on Node (node:zlib):

Messageone-shot deflaterate
108 B event1.1×129K/sec
1.4 KB callback6.3×105K/sec
24 KB callback13.7×35K/sec
SSE tick, one gzip member flushed per event7.8×90K/sec

The first row is why every per-message knob has a threshold (1 KiB): a small message barely shrinks without history to lean on. There are two ways to give it history. On a WebSocket, contextTakeover keeps a zlib window per connection (10.6× on a repeated shape, for ~160 KiB per direction per connection). Everywhere else — and with no state per connection — a dictionary.

When a codec fails ​

A message is never lost to compression: a codec that throws on the way out sends the message plain, and one that cannot read what came in refuses that frame and answers it. Which also means nothing looks broken — the traffic is just several times what it was. So the server says it:

  • compression.failed — a warn, once per carrier, direction and codec (carrier: http, sse, wt, rooms, cluster, broker; direction: encode or decode; codec; code and err), from the first failure on. A dictionary that stopped matching shows up here as decode failures on one side.
  • wrpc.compression.failures — the counter, every time, by wrpc.compression.carrier and wrpc.compression.direction; alert on its rate rather than on the line.
  • On a WebSocket the refused frame has its own line, frame.refused, with the codec and the code: ERR_BUFFER_TOO_LARGE is maxMessage, anything else the bytes.

Which algorithm ​

Deflate is what true means everywhere, and that is a measured choice, not an inherited one. bench/algorithms.js, one callback packet at a time through node:zlib — bytes out and encode time; deflate 3 is the platform codec's default, and every decode is 4–30 µs and decides nothing:

Plaindeflate 3deflate 6Brotli 4Brotli 11zstd 1
361 B199 B · 11 µs199 B · 11 µs195 B · 18 µs160 B · 706 µs203 B · 7 µs
1.8 KB385 B · 12 µs376 B · 16 µs334 B · 27 µs296 B · 2.2 ms396 B · 10 µs
27 KB3,351 B · 46 µs3,196 B · 118 µs2,601 B · 93 µs1,748 B · 33 ms2,848 B · 36 µs
255 KB30.3 KB · 411 µs28.0 KB · 1.3 ms23.6 KB · 995 µs15.2 KB · 351 ms26.7 KB · 283 µs

What follows from it:

  • Up to ~2 KB — where RPC messages live — the three are level, within a few bytes and microseconds. Deflate is also the one format every CompressionStream, every Node and the WebSocket extension have, and the one the router dictionary works best with (a 150 B event: 59 B with a deflate dictionary, 76 B with a zstd one). So it stays the default, and a browser-facing service needs nothing else.
  • Deflate's knee is level 3, the platform codec's default: zlib's levels 1–3 are its fast strategy and 4+ the lazy one, so level 4 is both slower and larger than 3, and level 6 buys 5% at 27 KB for 2.5× the CPU. deflateCompressor({ level: 6 }) when the bytes matter more.
  • From ~16 KB, zstd is a third of deflate-6's cost and 10% smaller: the pick for Node↔Node wires with large answers — the broker binding, the backplane, a Node WebSocket client — as codec: ['zstd', 'deflate-raw'], so an older Node still compresses.
  • Brotli 4 is the smallest at deflate-6's price: the pick when the link is the cost (mobile, metered egress). Never zlib's own default, quality 11 — it is an archive setting, milliseconds a message — which is why 'brotli' and brotliCompressor() exist instead of two lines of zlib.
  • SSE stays gzip, and says why: flushed per event the other two save nothing and hold 2–3× the memory per open response.
  • perMessageDeflate stays deflate because RFC 7692 is the only WebSocket extension a browser implements; there is no algorithm to choose there, only level, contextTakeover and async.

LZ4, Snappy and the rest are not in node:zlib, and wRPC installs nothing — but the seam takes them: wrap the package you chose in id, encode, decode and put it first in the list.

js
const lz4 = require('lz4'); // your dependency, not wrpc's

const lz4Codec = {
  id: 'lz4',
  threshold: 512,
  // The inflated size goes in front: LZ4's block format does not carry it.
  encode: (bytes) => {
    const out = Buffer.allocUnsafe(4 + lz4.encodeBound(bytes.length));
    out.writeUInt32LE(bytes.length, 0);
    return out.subarray(0, 4 + lz4.encodeBlock(bytes, out, 4));
  },
  // `decode` MUST refuse BEFORE it inflates — the cap is what bounds a
  // compression bomb, and checking `out.length` afterwards is too late, the
  // allocation has happened. The size read from the peer's bytes is only a
  // claim: what bounds the work is the buffer the decoder is handed.
  decode: (bytes, maxOutput) => {
    const view = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
    const size = view.readUInt32LE(0);
    if (size > maxOutput) throw new RangeError('inflated message exceeds the cap');
    const out = Buffer.allocUnsafe(size);
    if (lz4.decodeBlock(view.subarray(4), out) !== size) throw new RangeError('lz4: bad block');
    return out;
  },
};

attachBrokerRpc(server, broker, { compression: { codec: [lz4Codec, 'deflate-raw'] } });

On the loop, or on the threadpool ​

Every Node codec in wRPC deflates synchronously by default, and that is a measured choice, not an oversight. node:zlib has two APIs: the convenience calls that block the event loop for the length of the deflate, and the callback ones that hand the work to libuv's threadpool. The hand-off costs a fixed ~20 µs per call — a queue, a context switch, a copy back — and a message that takes microseconds to compress does not amortize it (bench/zlib-async.js):

Messagedeflate, syncdeflate, threadpool (one at a time)inflate, syncinflate, threadpool
291 B8.6 µs28.4 µs3.4 µs22.3 µs
2.6 KB15.9 µs35.8 µs4.3 µs22.8 µs
27 KB100 µs120 µs19 µs44 µs
276 KB970 µs1,007 µs148 µs259 µs
1.1 MB4.3 ms3.8 ms578 µs1,136 µs

Below ~256 KB the threadpool only makes the message slower; at 256 KB the two are level, and past it the loop is the thing being bought — a 1 MB result deflates in 4 ms, which is 4 ms during which no other connection is served, and fifty peers' worth of it is 200 ms. Inflate never earns the hand-off: it is eight times faster than deflate, so the fixed cost wins at every size up to the 16 MiB cap. That is why every knob has the same shape — synchronous, with an opt-in async threshold for the encode side only:

WireThe knobDefault threshold
WebSocket, permessage-deflateperMessageDeflate: { async: { threshold } } — performance. Lenient by design: an unusable threshold falls back to the default without a TypeError, because the ./ws engine's option predates the strict normalizer and must not import it256 KiB
HTTPhttp: { compression: { async } }256 KiB
Server-Sent Eventsnone — a gzip stream already runs its writes off the loop—
WebTransport, WebRTCcompression: { async } on the platform codec, dictionaryCompressor(dict, { async }) on the dictionary one256 KiB
The broker binding, the backplane, a Node WebSocket clientnone — refused: these carriers have no ordering queue to hide a promise behind—
js
// A host that answers megabyte results over data channels: those go to
// the threadpool, everything smaller stays on the loop.
new WrpcPeer({ router, signaler, compression: { async: true } });
acceptSessions(server, sessions, { compression: { async: { threshold: 128 * 1024 } } });

The WebTransport and WebRTC transports keep messages in order around the promise (the same queue a browser's CompressionStream already needs), so a small event sent after a large result still leaves after it. With sixteen large messages in flight the four threadpool threads bring a 276 KB deflate to 242 µs of loop time each — a burst is where the option pays; a single large message merely stops blocking. In a browser the question does not arise: CompressionStream is asynchronous by construction, and the pure-JS codec of @alexify/wrpc/deflate hands messages past nativeAbove (4 KiB) to it for the same reason.

The router dictionary ​

One-shot deflate sees every field name and every method target for the first time, in every message. A preset dictionary is those strings sitting in the window before the first byte, and your router already knows them:

js
const { buildDictionary, dictionaryCompressor } = require('@alexify/wrpc');

const dictionary = buildDictionary(router); // ~0.5–32 KiB, deterministic
const codec = dictionaryCompressor(dictionary);

await attachBrokerRpc(server, broker, { service: 'billing', compression: { codec } });
new Server({ router, compression: { codec }, rooms: { compression: { codec } } });

buildDictionary reads the router's introspection — field names from signature and schema, every unit/method target and unit/event name, then the packet skeletons every message starts with — ordered least to most frequent (zlib finds recent bytes cheapest and looks at the last 32 KiB), and cut from the front past the cap. Two instances of the same router build the same bytes, and the codec's id carries their hash, so the negotiation on each wire compares dictionaries, not just codecs: an instance whose router differs — a rolling deploy — names another id, and that pair stays plain rather than corrupt. bench/dictionary.js, on a 546 B dictionary from a small router:

Messageno historywith the dictionary
108 B event99 B (1.1×)55 B (2.0×)
84 B call77 B (1.0×)30 B (2.6×)
1.1 KB callback201 B (5.5×)160 B (6.9×)
27 KB callback1892 B (14.3×)1824 B (14.9×)

Throughput is the same (118K/sec against 111K on the event), so the dictionary codec's threshold is 64 B rather than 1 KiB: small messages are what it is for; a large one has all the history it needs inside itself.

Where it works with the platform codec: every Node↔Node carrier — the broker binding, the backplane envelopes, a Node WebSocket client's frames, a Node peer on WebRTC or WebTransport. A browser has only CompressionStream, which takes no dictionary — which is what the next section is for. On a WebSocket from a browser the dictionary is impossible in principle: the browser's own permessage-deflate does the compressing, and contextTakeover is the tool instead.

The dictionary in a browser: @alexify/wrpc/deflate ​

A DEFLATE codec in plain JavaScript, on its own subpath so a page that does not inject it never loads a byte of it (4.4 KB min+gzip when it does):

js
import { createDeflateCodec } from '@alexify/wrpc/deflate';

// `dictionary`: the same bytes the server built — served by the app, or
// built here from the same router by a browser peer.
const codec = createDeflateCodec({ dictionary });
const client = await connect(url, { transport: ['wt', 'ws'], compression: { codec } });
new WrpcPeer({ router, signaler, compression: { codec: createDeflateCodec({ dictionary: buildDictionary(router) }) } });

Its id is the Node dictionary codec's for the same bytes, so a browser peer on this codec and a Node peer on node:zlib negotiate with each other; without a dictionary it is the platform id, and the platform codecs read it. The inflater is complete — stored, fixed and dynamic blocks, whatever zlib or a CompressionStream on the other end chose — takes the dictionary, and is capped like every decoder here. A dynamic block costs what its header says — the code lengths it declares — plus a root decoding table of fixed width and the sub-tables a prefix-free code bounds by itself, never a table sized by the longest code a block claims: a stream of blocks that declare 15-bit codes and emit nothing buys no 128 KB table per block, so maxOutput is not the only cap. The encoder is deliberately simple: LZ77 against the dictionary, written as one fixed-Huffman block, because on the messages this exists for a dynamic tree costs more than it saves. bench/deflate-js.js:

Messagethe codec, dictionaryzlib, dictionaryCompressionStream
108 B event51 B, 700K/sec51 B, 128K/sec97 B, 22K/sec
2 KB callback263 B, 68K/sec204 B, 68K/sec250 B, 17K/sec
28 KB callback2645 B, 5.3K/sec1824 B, 11.5K/sec1892 B, 7.4K/sec

On the small message fixed codes produce the same bytes as zlib's dynamic ones, five times faster than zlib's one-shot API — and inflating it back runs at a million a second. A codec keeps its window and the dictionary's hash chains between messages, so a message costs what the message costs: the same 2 KB callback is 13 µs against a 4 KB dictionary and against a 32 KiB one (the one-shot deflateRaw, which hashes the dictionary on every call, takes 32 and 116). That state is about 0.2 MB plus five bytes per dictionary byte, built on the first message — make one codec per page or process, not one per connection. Past a couple of kilobytes fixed codes fall 30–45% behind, so the codec is a hybrid: from nativeAbove (4 KiB) up, a message goes to the platform's CompressionStream — dynamic Huffman, no dictionary, which a large payload does not need — and the output still inflates on a peer holding the dictionary. That hand-off is asynchronous and on by default in a browser only; in Node the codec answers synchronously whatever the size, so it works on the Node↔Node carriers too (where dictionaryCompressor on node:zlib is the faster choice anyway).

Tests are the main body of that subpath, not the codec: an interop matrix both ways against node:zlib at every level and strategy and against the platform streams, a fuzz corpus, and every malformed input answered with a coded DeflateError rather than a wrong byte.

The codec seam ​

Every per-message knob takes { codec }: anything with an id, encode(bytes) and decode(bytes, maxOutput) — isCompressor is the structural check, exported from the main entry. codec also takes the name of a platform codec — the ids are CompressionStream's format names, so a Node peer and a browser peer negotiate the same one:

codecNodeBrowserDefault level
'deflate-raw' — what compression: true meansevery Nodeevery CompressionStreamzlib 3
'brotli'every Nodesome (the constructor answers)quality 4
'zstd'22.15+ / 23.8+, a TypeError beforesome1
js
const { zstdCompressor, brotliCompressor } = require('@alexify/wrpc');

attachBrokerRpc(server, broker, { compression: { codec: 'zstd' } });
new RpcServer({ router, backplane, rooms: { compression: { codec: zstdCompressor({ level: 3 }) } } });

The factories (deflateCompressor({ level }), brotliCompressor({ quality }), zstdCompressor({ level }), Node only) are for another level, threshold or async than the name takes; an id names the format, never the level, so two ends on different levels still negotiate. The defaults are measured (bench/algorithms.js), not zlib's — and for Brotli that matters: zlib's own default is quality 11, which takes 33 ms on a 27 KB answer where quality 4 takes 93 µs. In a browser a format the platform lacks leaves compression off rather than throwing. The dictionary codec above is another injection; yours is another still. Either method may answer a promise on the socket transports (a CompressionStream can only), and the transport keeps messages in order around it; the Node↔Node carriers require a synchronous answer and say so at construction. decode must stop at maxOutput bytes — that cap is what bounds a compression bomb to the size a plain message is already bounded at.

Released under the MIT License.