fast-brotli-wasm
The same Rust brotli encoder as brotli-wasm, with its hot paths rewritten so that the compressed bytes come out identical. A new decoder handles decompress(), and the wasm file is a third smaller.
"brotli-wasm": "npm:@r1ck404/fast-brotli-wasm@3.0.3" in your package.json is the whole migration.
- 3xcompress at quality 11, 51 KB to 536 KB
- 13xcompress of a 70-byte input
- 2xdecompress
- 33-46%smaller wasm
Compress
Decompress the result
- Input
- Compressed, both libraries
- The browser's own gzip
Why quality 11 was the slow part
Calling brotli-wasm's compress() without options means quality 11, the highest setting, so that is what a caller gets by default. Nodepod's zlib polyfill takes the default. Qualities 10 and 11 work differently from the rest. They use a binary-tree match finder and then search for the cheapest way to write each block, in the manner of Zopfli: for every position they gather candidate matches, price each choice in bits with a cost model, and keep the cheapest path through the block. Quality 11 tries more start positions and longer matches than quality 10. Lower qualities skip most of that work, so the time per byte rises steeply near the top. The quality sweep below shows where.
Decompression has no such search. There is one correct output and the decoder only has to produce it, so it is already fast, and there is less to gain.
What runs where
compress(bytes, { quality }), decompress(bytes), CompressStream and DecompressStream, exactly as before.core.mjs does what wasm-bindgen's generated code does: it copies inputs the same way, throws strings from compress and decompress and Errors from the streams, and hands options to Rust as JSON.decompress(). Streams, and any input it does not accept, go to the reference decoder, so errors come from the original code.fastbrotli.wasm, 693 KB (228 KB brotli). The encoder's big lookup tables are stored packed and rebuilt at first use.behaves as brotli-wasm's own code doesnew machinery
One rule: the same bytes
Making an optimizer faster usually means letting it decide differently. That is not allowed here. Code that hashes compressed output, keys a cache on it, or compares it with a fixture would notice a different, if valid, file. So the encoder keeps every decision it makes, including the tie-breaks between equal costs and the floating-point arithmetic that produces those costs. What changed is how the numbers are computed and how much of the work is skipped, and work is skipped only where the source shows it cannot change the result.
Zopfli relaxation
This is where quality 10 and 11 spend their time, and where most of the rewrite went. For each position the search keeps a few of the cheapest start positions and, from each, relaxes the cost of every later position a candidate match can reach. Several parts of that are now cheaper.
- The cost of a copy command depends on its insert length code and its copy length code. The rewrite builds one row of command costs per insert code and relaxes four copy lengths at a time, with the same float operations.
- The original keeps eight start positions in a ring but only ever reads the first few. The new queue holds only those, and they are the same entries.
- Probing the 16 recent distances depends only on a start position's distance cache, and start positions often share one. The hits are found once and replayed with each entry's own costs.
- The second start position is skipped when its base cost is not lower and every command cost for its insert code is at least as high as the best one's. Float addition never makes a sum smaller, so none of its candidates could have won.
Finding matches, splitting blocks, grouping histograms
The match finder compares bytes 16 at a time with WebAssembly SIMD and visits candidates in the same order with the same checks. The static dictionary lookup does the same. Block splitting evaluates its per-symbol costs with SIMD, following the scalar loop's rule for which histogram wins. Histogram clustering tracks which bins are nonzero, computes the cost of a merged pair without building the merged histogram, and stops as soon as it knows the pair is not below the limit.
Work that never needed doing
The encoder's ring buffer is 8.3 MB at the default window, and at quality 10 and 11 the match finder allocates a forest of 32 MB. The original clears both. The rewrite does not, because every byte that is read has already been written, and it zeroes only the few bytes past the data that can be read early. When the caller passes the whole input, as compress() does, the forest is sized to the input instead of the window. That cost does not depend on the input size, so it is the likely reason the smallest inputs gain the most. Two pieces of code are compiled out because brotli-wasm only ever sets the quality: the per-metablock logging, and the stride and prior evaluators that only that logging runs.
Compresses the chosen input at every quality with both libraries and compares the outputs. Takes several seconds, most of it at qualities 10 and 11.
What the sweep shows
Look at two columns. On the default input, time stays within a small range up to quality 9 and then jumps at 10 and 11, where the Zopfli-style search starts. Size keeps shrinking past 9, but by a few percent, not by anything like the factor that time grows. That is the trade quality 11 makes, and it suits data that is compressed once and read many times. Try the other inputs: the numbers move, the shape should not.
The speedup is not confined to the top. Faster match finding and less clearing of memory help every quality, and the speedup column shows it. The recorded results below say the same: 2.7x at quality 1 and 2.4x at quality 5 on 1 MB, and about 3x at quality 11.
A new decoder
decompress() runs a decoder written for wasm. It writes straight into one output buffer, and since that buffer holds the whole history, there is no ring buffer and no wrap handling. A 64-bit bit reader refills without branches from an 8-byte load. The tables it builds are fused: one lookup returns the symbol and its decoding information together, such as command lengths and distance parameters. Matches are copied in 16-byte SIMD chunks.
Its contract is narrow. It returns a result only for inputs on which the reference decoder returns the same bytes. Every error, and the few legal streams whose reference behaviour depends on its internal 4 KB chunking or that use large windows, go to the reference decoder, so error messages come from the original code path. It also mirrors the reference decoder's quirks, such as ignoring bytes after the last meta-block. Streams (DecompressStream) still use the reference decoder, which is why they gain 1.5x where decompress() gains about 2x.
Faithful down to the odd calls
brotli-wasm's behaviour includes a lot that is not compression. It reads options as JSON through serde_json, so a quality of "5" (a string) or 5.5 (a float) is not coerced but panics with serde's error message, printed to the console by a panic hook, and throws RuntimeError: unreachable. The hook prints only the first two panics of an instance. Failed compressions and decompressions throw strings, while the stream classes throw Errors.
fast-brotli-wasm has no serde. Its options parser and its JavaScript layer reproduce those results, including the float formatting inside the message. The panel below runs a set of these calls through both libraries and compares what comes back and what reaches the console.
| Call | brotli-wasm | fast-brotli-wasm | Match |
|---|
Why the wasm is smaller
693 KB (228 KB brotli) against 1.01 MB (385 KB brotli), a third smaller as it sits on disk and roughly 40 to 46 percent smaller after brotli or gzip. Three changes account for it.
- No serde and no float formatting code. Options are parsed by a small parser built for size, and the one place that needs a formatted float leaves it to JavaScript.
- No metablock logging or its evaluators, as above.
- The encoder's four big lookup tables, 518 KB in all, are not stored. They are kept packed in 115 KB and rebuilt the first time an encoder is created.
test/tables.mjschecks the rebuilt tables against brotli-wasm's own, entry by entry. The rebuild is part of first-call time: in Node,require()plus the firstcompress()takes 12.7 ms against 22.8 ms for brotli-wasm.
The wasm file plus the JavaScript that loads it, as measured for this site: raw, gzip level 9, and brotli. Both bars in a group share one scale.
Where the speedup stops
At quality 11 the gain settles near 3x from a few tens of kilobytes upward. A native x86 build of the same encoder is no faster than the wasm, so the remaining time is the algorithm itself, not the platform. Faster versions exist, such as reordering relaxations across queue entries, but they can change tie-breaks and so the output, which this package does not allow. Other attempts gained nothing: probing the distance cache out of line, 16-byte queue entries, prefix tables for the dictionary, and prefetching. The dead ends are written down, and fast-pako's deflate stops near 2x for the same reason.
What is different
The wasm is built with SIMD (simd128), so it needs an engine that supports it; current Chrome, Firefox and Safari do. In browsers it is fetched from next to the JavaScript, as brotli-wasm's is. In Node, require() is synchronous and needs Node 20.19 or later. Stack traces inside panic reports differ from brotli-wasm's, since the frames are different code; the messages are the same.
The claim that the output is identical rests on tests rather than on the source alone. The compressor was compared with brotli-wasm at qualities 0 to 11 on 4,892 and 18,116 files from the pnpm store, the decoder in 290,446 checks that include corrupt streams and their errors, and the options parser in 25,655. How we check lists them all.
Recorded results
Measured on a Ryzen 7 7800X3D with Node 24.16, one call per row: brotli-wasm's time, fast-brotli-wasm's time, and the ratio. Best of three rounds in fresh processes.
compress() without options uses quality 11, the highest setting, so it is what a caller gets by default (it is what Nodepod's zlib polyfill calls).
| call | input | brotli-wasm | fast-brotli-wasm | speedup |
|---|---|---|---|---|
compress, quality 11 (default) | 70 B | 2.31 ms | 0.18 ms | 13x |
compress, quality 11 (default) | 1.6 KB | 4.30 ms | 0.94 ms | 4.6x |
compress, quality 11 (default) | 8 KB of JSON | 11.4 ms | 3.3 ms | 3.4x |
compress, quality 11 (default) | 51 KB | 56.9 ms | 19.0 ms | 3.0x |
compress, quality 11 (default) | 536 KB | 686.0 ms | 236.8 ms | 2.9x |
compress, quality 5 | 1 MB | 30.8 ms | 12.7 ms | 2.4x |
compress, quality 1 | 1 MB | 16.0 ms | 5.9 ms | 2.7x |
decompress | 51 KB | 113.8 µs | 56.3 µs | 2.0x |
decompress | 1 MB | 2.21 ms | 0.96 ms | 2.3x |
CompressStream, quality 5 | 1 MB | 30.8 ms | 12.8 ms | 2.4x |
DecompressStream | 1 MB | 2.27 ms | 1.55 ms | 1.5x |