fast-packages

How we check

A replacement is only worth installing if it gives the same answers and its speedup is real. This page shows how both are established, what it costs in size, and what was tried and dropped.

  • 2 million+comparisons against the originals
  • 41 of 41planted bugs caught in noble-hashes
  • 0failures in the last full run

Proving the results are identical

Every package is tested the same way. The original is installed next to the replacement, both receive the same input, and everything observable is compared: return values, byte for byte; thrown errors, including their class, message and position; callbacks; and for stateful objects, the state after every call. The inputs are mostly real code, such as tens of thousands of files from node_modules and from a large project's pnpm store, plus fuzzed and deliberately malformed variants and, for some packages, the original project's own test inputs.

The tests for each package are listed in its README. The counts below are from the last full run, and all of them passed with no mismatches.

PackageWhat is comparedVolume
fast-pakoLevels, strategies, memory levels, window sizes, chunk sizes, dictionaries, gzip headers, multi-member and truncated streams, string output, corruption fuzzing, streaming with flush modes, odd option and input types7,864 equivalence checks, 28,731 fuzz checks over two seeds, 15,457 unusual-value checks, 2,356 on real files and tarballs
fast-acornASTs (keys, key order, values, shared objects), errors (class, message, position, location), callbacks, JSX, subclasses, every ecmaVersion and option93,841 parses and 8,849 errors over 14,670 files, 309,982 checks across versions and options, 274,791 error cases, 124,645 JSX checks
fast-esbuild-wasmCode, source maps, warnings and errors of transforms; CSS; builds with plugins and with a real file system; the resolver; messages at every log level; the Node API and command line126,258 JS and TS outputs over 3,944 files and 57 option sets, 94,144 CSS outputs, 73,724 resolves, about 2,000 builds
fast-es-module-lexerImports and exports found, and errors, in four ways of copying the source into memory, in Node, Chromium, Firefox and WebKit158,904 inputs per copy mode, 125,504 edge cases (100,000 fuzzed), 4,424 per browser
fast-brotli-wasmCompressed bytes at every quality from 0 to 11, decompression of native, generated and fuzzed streams including their errors, options parsing and its panics23,008 compressions, 290,446 decoder checks, 25,655 options checks
fast-noble-hashesDigests and every field of every hash instance after every call, errors, HMAC, HKDF, PBKDF2 and scrypt with progress callbacks, subclasses, tampered instances113,334 per module format and 113,120 on a second seed, 597 per browser on the main thread and in a worker

Checks written separately

A second set of tests, in the repository's verify/ folder, was written apart from the package suites. It calls each package the way a real application does, using Nodepod's calls: its content packing for pako, its exact topLevelParser for acorn, lockfile integrity hashes of real npm tarballs for noble-hashes, and the public transform() API for esbuild. It found no differences either, across 37,913 pako, 54,816 acorn, 40,041 lexer, 7,906 Brotli, 17,308 hash and 8,075 esbuild checks. Every browser build was also run in headless Chromium, and the packed tarballs were installed under the original names and used from Node and from a bundler.

Testing the tests

A passing suite proves little if it could not fail. For fast-noble-hashes the suite is mutation tested: 41 deliberate bugs are planted in the glue, the patched files and the WebAssembly, and the suite has to catch every one. It does.

What is not identical

There are differences, and each package's README lists them. They are mostly places where the original loops forever, corrupts data or reads memory it does not own, and the replacement throws a clear error or gives a stable answer instead. Each one is pinned by a test, so it cannot change unnoticed.

How the timings are taken

The tables on each package's page come from the repository's benchmark suites. They run on a Ryzen 7 7800X3D with Node 24.16 and Chromium 149. The two implementations alternate in fresh processes, so neither inherits the other's warmed-up JIT state or heap, and each row keeps the best of three rounds. Alternating means a background task or a change in CPU frequency hits both sides alike.

The live comparisons on this site work a little differently, because they run in your tab: both libraries take turns in short batches in the same page, each is warmed up first, and each keeps its best batch. That is quicker and shows real numbers on your hardware, but it is less controlled, so treat the recorded tables as the reference and the live numbers as a check that they hold up on your machine.

One caveat sits in the esbuild results. In Node, esbuild-wasm waits for the operating system's timer, which is 15.6 ms on Windows, on every call. That inflates the speedup on small inputs, which is why the browser tables are the fairer comparison and why native esbuild is listed next to the Node numbers.

Start-up

Most of the packages start about as fast as the originals. pako and Brotli start faster; each hash family builds and compiles its WebAssembly at first use, so noble-hashes' first call is slower, and that cost is paid back after a few kilobytes of input.

Fresh processes, medians (Node; esbuild in Chromium):

originalfast
import "acorn"5.4 ms5.1 ms
import "acorn" + first parse9.4 ms9.5 ms
import "pako"4.0 ms2.3 ms
import "pako" + first ungzip of a 1.4 MB tarball49.7 ms12.2 ms
import "es-module-lexer"1.6 ms1.5 ms
import + await init + first parse2.6 ms2.4 ms
import + initSync() + first parse2.2 ms2.3 ms
import "@noble/hashes/sha256"7.1 ms7.4 ms
first sha256() call0.3 ms1.2 ms ยน
require("brotli-wasm") + first compress()22.8 ms12.7 ms
esbuild initialize() + first transform() (Chromium)152 ms56 ms

The first sha256() call builds and compiles its WebAssembly, about 1 ms.

Size

Speed is not free in bytes, and the results are mixed. The tarballs that npm downloads are almost all smaller, because none of them carries a copy of the original. In a browser bundle, the packages whose fast path is compiled WebAssembly are larger after compression, because wasm compresses worse than JavaScript.

What an app ships: the browser bundle of the imports the benchmarks use, which are the ones Nodepod uses (esbuild, minified; min / gzip / brotli), plus the wasm files fetched at run time for brotli-wasm and esbuild-wasm; and what npm downloads and installs. of the original any more.

bundleoriginalfast
pako (default import)47.3 / 15.1 / 13.6 KB45.6 / 30.6 / 29.2 KB
pako { ungzip }47.2 / 15.0 / 13.6 KB44.0 / 30.0 / 28.7 KB
acorn (namespace)121.6 / 34.5 / 28.9 KB118.7 / 41.3 / 36.3 KB
acorn + acorn-jsx255.0 / 72.6 / 40.1 KB132.1 / 46.6 / 40.0 KB
es-module-lexer { initSync, parse }14.1 / 6.5 / 5.8 KB21.6 / 9.3 / 8.5 KB
brotli-wasm (JS + wasm)1.06 MB / 576.5 / 395.8 KB713.7 / 309.4 / 235.8 KB
@noble/hashes, Nodepod's imports17.3 / 7.5 / 6.2 KB16.9 / 7.0 / 6.2 KB
@noble/hashes/sha256 alone4.7 / 2.4 / 2.0 KB7.6 / 3.6 / 3.3 KB
@noble/hashes/sha512 alone7.5 / 3.6 / 3.1 KB8.0 / 3.8 / 3.4 KB
esbuild-wasm (JS + wasm)14.05 / 3.77 / 2.72 MB2.17 / 0.51 / 0.40 MB (no wasm)
packageoriginal tarball / unpackedfast tarball / unpacked
pako412.5 KB / 1.64 MB43.5 KB / 93.3 KB
acorn133.5 KB / 565.3 KB85.7 KB / 320.6 KB
acorn-jsx7.6 KB / 24.4 KB7.5 KB / 24.3 KB
es-module-lexer31.2 KB / 93.4 KB29.7 KB / 76.4 KB
brotli-wasm1.74 MB / 3.25 MB318.6 KB / 746.9 KB
@noble/hashes155.3 KB / 1.15 MB74.9 KB / 469.8 KB
esbuild-wasm3.86 MB / 14.53 MB2.73 MB / 12.36 MB

Where a fast package is still larger, it is the fast engine itself: compiled wasm compresses worse than the original's JavaScript (pako's zlib, the SIMD lexer), and the per-family glue outweighs noble's tiny SHA-256 function. Shrinking these further cost more speed than the rules allow, which are that every call stays faster than the original and none becomes more than 10% slower than the previous version.

Dead ends

Things tried that did not pay off are kept in the repository so nobody repeats them. Here they are, grouped by package.

pako

  • Deflate at levels 6 to 9 is bounded by zlib's match finder, which must visit the same candidates for the output to be identical: about 2x at level 6, 1.7 to 2x at level 9, which is native zlib speed.
  • For inflate, these were all slower or no better: fused length entries (10 to 17% slower), decoding 4 or 5 literals per refill, 9 or 10-bit distance tables and 10 or 12-bit literal tables, and wasm-opt -O4.
  • For size, compiling the core for size cost 1.7 KB but made deflate 13 to 43% slower, and inflating the embedded module with pako's own JavaScript took 4.6 to 6 ms cold. The compressed wasm alone is 21 KB, larger than pako's gzipped JavaScript, so the bundle stays about twice pako's gzip size.

acorn

  • Lazily created token start positions, interning identifiers, and object literals for AST nodes (node creation was 4x slower) did nothing or made it worse.
  • Shipping several files (import time 7.5 to 8.9 ms) or an unminified bundle (about 6.0 ms) instead of one minified file was slower to import. esbuild's bundler converts to var, which costs 5 to 10% on large parses, so the package is bundled with Rollup. Reusing the parse state made parsing 4 to 16% slower.
  • The gzip and brotli sizes stay about 20% above acorn's, although the minified size is below it, because the fast machinery compresses less well. The cost of locations is dominated by garbage collecting the position objects that the AST has to contain.

esbuild

  • From when the package still shipped esbuild's Go binary: tuning Go's garbage collector (1.3x on big inputs, but it grows wasm memory that never shrinks), wasm-opt -O2 on the Go binary (no speed gain, 3.4% smaller), and a TinyGo build, which builds but whose runtime cannot run esbuild's asynchronous stdin loop.
  • Object literals for AST nodes and interning identifier strings gave no gain. The eventual answer was to remove the Go binary altogether.

es-module-lexer

  • A JavaScript charCodeAt copy of the source (about 2.5 ns per character) and btoa plus setFromBase64 (about 1 ns per character) both lost to the copy paths now used. Relaxed-SIMD swizzle would add 3 to 12%, but would need a second module for engines without it.
  • V8's baseline code for the builtins copy loop is slow, so the first million characters use intoCharCodeArray instead.
  • For size, compiling at opt-level z saved 0.2 KB gzipped and was up to 13% slower, and dropping the Chromium copy module saved 0.5 KB but was 37 to 135% slower in Chromium. A wasm small enough to match the original's 6.5 KB bundle would mean giving up SIMD.

brotli

  • A native x86 build of the same encoder is no faster than the wasm, so what is left is the algorithm. Reordering relaxations across queue entries would be faster but can change tie-breaks, and so the output.
  • Probing out of line, 16-byte queue entries, dictionary prefix tables and prefetching gave no gain.

noble-hashes

  • A precomputed message schedule for SHA-256 was about 30% slower than keeping it in locals. Reusing the previous round's a ^ b in Maj gave 5% on SHA-256 and nothing on SHA-512, and wasm-opt -O3 changed nothing.
  • SHA-256 stays near 1.5x: scalar SHA-256 in wasm against a JIT that already does 32-bit rotates well. The create() path is floored by noble's constructor, about 270 ns in Node, mostly the ArrayBuffer behind its buffer. Pooling those buffers would be faster but would let instances share memory.
  • Rolled SHA-2 rounds ran at 0.79 to 0.97x of the speed, so the kernels ship as templates that are expanded to unrolled code at first use. A lone sha256 import stays above noble's size: noble's JavaScript SHA-256 is about 0.6 KB brotli-compressed, and the glue alone is about 1.9 KB.

A bug worth remembering

  • Bundlers drop calls annotated /* @__PURE__ */ whose result is unused. fast-noble-hashes' wasm start-up was exactly such a call, so every bundled build silently ran noble's JavaScript. It is fixed, and noble, es-module-lexer and acorn now have bundle tests that would catch it.