fast-packages

fast-pako

pako's zlib, ported to Rust and compiled to WebAssembly. It returns the same bytes, the same errors and the same streaming callbacks, in a fraction of the time.

"pako": "npm:@r1ck404/fast-pako@2.1.2" in your package.json is the whole migration.

  • 5-6xungzip of npm tarballs
  • 4xinflate
  • ~2xdeflate at levels 6 and 9
  • 9.5xsmaller npm tarball

Race pako against fast-pako

How this is measured. Both libraries run in this tab, one after the other in short batches, and each keeps its best batch. Decompression inputs are made from the chosen file with pako itself. Files you choose stay in your browser.

Why unzipping was the slow part

npm tarballs are gzip files, so anything that installs, inspects or serves packages has to unpack them, and any app that handles .gz downloads or writes archives does the same work in one direction or the other. Nodepod, which installs npm packages inside a browser tab, does it for every dependency. pako is a widely used JavaScript implementation of zlib, and gzip decoding is where most of a big install's decompression time goes: a 4.4 MB tarball takes pako 100 ms and fast-pako 17 ms.

Inflate is a tight loop. It refills a bit buffer, looks up a Huffman code in a table, and either writes a byte or copies a run from earlier output. A JavaScript engine runs that loop well, but it has to guard its type guesses, and pako keeps its bit buffer in a JavaScript number, which follows JavaScript's rules for integers. WebAssembly is typed ahead of time, so there are no guesses to guard, and it gives direct access to 64-bit integers and SIMD.

What runs where

Your codepako.ungzip(bytes), exactly as before.
JavaScript layerindex.mjs reproduces pako's option handling, chunking, onData and onEnd, string output and error messages.
Engine in Rustpako's zlib ported function by function: inflate, deflate, trees, crc32, adler32. Compiled to WebAssembly.
Embedded moduleThe wasm ships inside the JavaScript as compressed text and is unpacked on first use by a 2 KB wasm decoder, about half a millisecond.

behaves as pako's own code doesnew machinery

What makes the inflate loop fast

The state machine around the loop is pako's, case for case, so everything you can observe from outside stays the same. The loop itself is new, in the style of libdeflate: one decode table per code, a 64-bit bit buffer that refills without branching, the next table entry loaded ahead of time, and matches copied 16 bytes at a time.

The two checksums that come with a stream get the same care. adler32 uses SIMD, adding up 16 bytes per step. crc32 has a table-free path for big buffers: the CRC-32 polynomial divides a sparse polynomial with only five terms, so a long message can be reduced with plain XORs of 64-bit words instead of table lookups. Small buffers use slice-by-8 tables. That is where the 4x on plain inflate comes from, and why tarballs, which pay for the checksum too, gain a little more.

Why tiny inputs gain even more

Compressing 75 bytes is 20 times faster, and that has little to do with the engine. pako allocates its window and hash tables anew for every call, and for a few hundred bytes that allocation is most of the time. fast-pako pools its streaming sessions and builds its larger tables the first time they are needed.

Compression level against speedup

Compresses 300 KB of JavaScript at each level with both libraries. Takes a few seconds.

Why compression stops near 2x

Level 1 is the fast, shallow setting, and there the speedup is about 3x. At levels 6 to 9, zlib searches harder for matches, and the speedup settles around 2x. The reason is that fast-pako's output must match pako's bytes exactly. zlib decides what to emit by walking the same candidate matches in the same order, so a faster search that looks at fewer candidates, or looks at them in another order, would produce a different, if valid, file. Code that hashes compressed output, or compares it to a fixture, would notice. So the match finder is a faithful port and the remaining speed comes from everything around it. At that point it is running at about the speed of native zlib.

Decompression has no such ceiling, because there is only one correct output, so the decoder is free to be as clever as it likes.

Faithful down to the odd calls

A drop-in replacement has to match on more than the happy path. pako has a lot of small behaviours that come from how JavaScript coerces values: a level of "5" selects a row of its parameter table but is not equal to 5, so it takes a different code path. Anything pako's own code throws, such as TypeError messages from calling a method that is not there, is produced by the same expressions in fast-pako, so the messages are the same.

The panel below runs a handful of these calls through both libraries and compares what comes back.

Odd calls, same answers

Callpakofast-pakoMatch

What is different

Where pako never returns, or hands back corrupt data, fast-pako throws an error that starts with fast-pako:. Examples are a chunkSize of 0, which makes pako loop forever, or a window smaller than 512 bytes, which makes pako write garbage. Each case is pinned by a test.

It also needs WebAssembly. Without it the first call throws a clear error. And pako's pako/lib/* and pako/dist/* subpaths are not provided, only the package's main entry.

On size, the picture is mixed and worth knowing. The npm tarball is 43.5 KB against pako's 412.5 KB, because nothing of pako is bundled. A browser bundle is 45.6 KB minified against 47.3 KB, but compresses less well: 30.6 KB gzipped against 15.1 KB, because compiled wasm compresses worse than JavaScript. Making it smaller cost more speed than the project's rules allow, and the attempts are written down.

Recorded results

Measured on a Ryzen 7 7800X3D with Node 24.16, one call per row: pako's time, fast-pako's time, and the ratio. Best of three rounds in fresh processes.

callinputpakofast-pakospeedup
ungziptypescript 5.9.3 npm tarball (4.4 MB)99.8 ms17.3 ms5.8x
ungzipreact-dom npm tarball (1.4 MB)33.0 ms5.6 ms5.9x
ungzipthree npm tarball (4.6 MB)95.9 ms18.4 ms5.2x
ungzip300 B9.2 µs2.2 µs4.2x
inflate1 MB of JavaScript3.18 ms0.76 ms4.2x
inflate9 MB of JavaScript28.1 ms6.7 ms4.2x
Inflate stream, 16 KB pushestypescript tarball98.3 ms23.5 ms4.2x
deflateRaw, level 11 MB of JavaScript9.9 ms3.2 ms3.0x
deflate, level 6 (default)75 B80.6 µs4.0 µs20x
deflate, level 6 (default)1 KB of text68.5 µs11.8 µs5.8x
deflate, level 6 (default)1 MB of JavaScript32.8 ms15.7 ms2.1x
deflate, level 91 MB of JavaScript140.2 ms64.3 ms2.2x
gzip300 B39.9 µs6.1 µs6.5x