285 lines
15 KiB
Markdown
285 lines
15 KiB
Markdown
# Benchmarks
|
||
|
||
A fair, reproducible comparison of **@zip.js/zip.js** against
|
||
[jszip](https://github.com/Stuk/jszip), [fflate](https://github.com/101arrowz/fflate)
|
||
and [archiver](https://github.com/archiverjs/node-archiver) on a set of realistic
|
||
workloads.
|
||
|
||
The numbers below are honest: zip.js wins clearly on some workloads and loses on
|
||
others. The goal is to show *where* each library is the right tool, and to give you a
|
||
harness you can re-run on your own hardware — the results are machine-specific and you
|
||
should not trust anyone's benchmark (including this one) without reproducing it.
|
||
|
||
> **TL;DR** — For compressing large or multiple entries, zip.js is the fastest option
|
||
> in the field, and it is the only one that parallelizes compression across CPU cores
|
||
> **without spawning a single Web Worker** — it lets the platform's native
|
||
> `CompressionStream` run on the threadpool while you simply issue concurrent `add()`
|
||
> calls. It also streams arbitrarily large files at a flat, low memory ceiling. For
|
||
> deflating thousands of tiny buffers in one shot, **fflate** remains the throughput
|
||
> and footprint champion. Against native tooling, zip.js on a modern runtime is a bit
|
||
> faster *and* tighter than 7-Zip's fast mode on single-file streaming; 7-Zip keeps a
|
||
> real edge only on thousands of tiny files.
|
||
|
||
## Environment
|
||
|
||
| | |
|
||
|---|---|
|
||
| Machine | Apple M2, 8 cores (4 performance + 4 efficiency), 16 GB RAM |
|
||
| OS | macOS 26.5.1 (arm64) |
|
||
| Runtime | Node.js v24.12.0 |
|
||
| zip.js | 2.8.29 |
|
||
| jszip | 3.10.1 |
|
||
| fflate | 0.8.3 |
|
||
| archiver | 8.0.0 |
|
||
|
||
## Method
|
||
|
||
- **Isolation.** Each `(library, operation, workload)` combination runs in its own
|
||
freshly-spawned Node process under `/usr/bin/time -l`, so there is no cross-library
|
||
GC or heap contamination and peak memory is a true per-library figure.
|
||
- **Timing.** `performance.now()` around the measured operation only. Every combination
|
||
runs **3 times**; the table reports the **median**.
|
||
- **Memory.** Peak resident set size (RSS) reported by `/usr/bin/time -l`. The "peak"
|
||
column is the delta over an empty-process baseline (~42 MB), i.e. the memory
|
||
attributable to the work.
|
||
- **Fair work.** All libraries compress at **DEFLATE level 6**. Output sizes are shown
|
||
so you can confirm each library did equivalent work. The corpus is generated from a
|
||
seeded PRNG, so every library sees byte-for-byte identical input.
|
||
- **zip.js modes.** zip.js is measured both **single-threaded** (apples-to-apples with
|
||
the single-threaded libraries) and with its **Web Worker** pool, so the worker
|
||
overhead is never hidden.
|
||
|
||
## Compression — single process, single thread
|
||
|
||
Level-6 DEFLATE, one entry (or one batch) compressed in the main thread. This is the
|
||
apples-to-apples comparison against the single-threaded libraries.
|
||
|
||
| Workload | @zip.js/zip.js | jszip | fflate | archiver |
|
||
|---|--:|--:|--:|--:|
|
||
| Compressible text (20 MB) | **795 ms** | 1770 ms | 953 ms | 720 ms |
|
||
| Incompressible data (20 MB) | 446 ms | 883 ms | **306 ms** | 366 ms |
|
||
| Already-compressed media (20 MB) | 452 ms | 886 ms | **298 ms** | 358 ms |
|
||
| 5,000 files × ~2 KB | 845 ms | 887 ms | **287 ms** | 419 ms |
|
||
|
||
Peak memory for the same runs (Δ over baseline):
|
||
|
||
| Workload | @zip.js/zip.js | jszip | fflate | archiver |
|
||
|---|--:|--:|--:|--:|
|
||
| Compressible text (20 MB) | 92 MB | 65 MB | **60 MB** | 82 MB |
|
||
| Incompressible data (20 MB) | 141 MB | 93 MB | 106 MB | **83 MB** |
|
||
| Already-compressed media (20 MB) | 142 MB | 94 MB | 106 MB | **83 MB** |
|
||
| 5,000 files × ~2 KB | 266 MB | 270 MB | **102 MB** | 118 MB |
|
||
|
||
zip.js is fastest on compressible text and competitive with archiver elsewhere, at
|
||
roughly comparable compressed sizes. **fflate** is the clear winner on raw throughput
|
||
and footprint for incompressible data and for large numbers of tiny files — if that is
|
||
your workload, use fflate.
|
||
|
||
## Parallelism & codec backends — 8 files × 8 MB, single process
|
||
|
||
This is the headline. The same 64 MB of compressible entries, compressed several ways
|
||
in a **plain Node process** (no worker threads unless noted). zip.js can select its
|
||
codec backend at runtime — native `CompressionStream`, the bundled WebAssembly zlib, or
|
||
a pure-JavaScript zlib port — and it can issue `add()` calls concurrently.
|
||
|
||
| Configuration | Median time | vs jszip |
|
||
|---|--:|--:|
|
||
| **zip.js — `CompressionStream`, concurrent `add()`** | **657 ms** | **8.8×** |
|
||
| fflate — async (its own worker pool) | 729 ms | 7.9× |
|
||
| archiver — Node zlib (libuv threadpool) | 2266 ms | 2.5× |
|
||
| zip.js — `CompressionStream`, sequential | 2453 ms | 2.3× |
|
||
| fflate — `zipSync` (single thread) | 3103 ms | 1.9× |
|
||
| zip.js — WASM zlib | 4176 ms | 1.4× |
|
||
| zip.js — pure-JS zlib | 4709 ms | 1.2× |
|
||
| jszip (pako, single thread) | 5758 ms | 1.0× |
|
||
|
||
**The point:** zip.js with the native `CompressionStream` goes from **2453 ms
|
||
sequential to 657 ms with concurrent `add()` — a 3.7× speedup — using no Web Workers at
|
||
all.** The native codec runs on the platform's threadpool, so independent entries
|
||
compress on multiple cores while your code stays on the main thread. That makes it the
|
||
fastest configuration measured, narrowly ahead of fflate's dedicated worker pool.
|
||
|
||
One honest caveat, visible in the table: **only the native `CompressionStream` backend
|
||
parallelizes this way.** The WASM and pure-JS backends run synchronously on the main
|
||
thread, so concurrent `add()` does not speed them up (4176 ms and 4709 ms whether
|
||
sequential or "parallel"). Use those backends when a native `CompressionStream` is
|
||
unavailable or when you need byte-identical zlib output; use the native backend when you
|
||
want this parallelism.
|
||
|
||
There is a second way to land on the WASM backend by accident: because
|
||
`CompressionStream` exposes no level control, requesting any non-default compression
|
||
level (e.g. `{ level: 5 }`) makes zip.js fall back to the WASM zlib codec — which, per
|
||
the table, does not parallelize via concurrent `add()`. Keep the default level to keep
|
||
the native-backend parallelism, or pair a custom level with Web Workers (below).
|
||
|
||
### Parallelism is runtime-dependent
|
||
|
||
The table above is measured on **Node**, and its "no Web Workers needed" result does
|
||
**not** hold on every runtime: concurrent `add()` only spreads across cores if the
|
||
runtime runs `CompressionStream` off the main thread. Same 8 × 8 MB workload, level 6,
|
||
median of 3:
|
||
|
||
| Runtime | sequential | concurrent `add()` | concurrent `add()` + `useWebWorkers` |
|
||
|---|--:|--:|--:|
|
||
| Node.js | 2.94 s | **0.74 s** | 0.74 s |
|
||
| Bun | 1.80 s | **0.36 s** | 0.46 s |
|
||
| Deno | 1.84 s | 1.82 s | **0.47 s** |
|
||
|
||
- **Node and Bun** back `CompressionStream` with a threadpool, so concurrent `add()`
|
||
alone parallelizes — no Web Workers needed (Bun is fastest here).
|
||
- **Deno** runs `CompressionStream` on the isolate thread, so concurrent `add()` alone
|
||
gives no speedup (1.82 s ≈ its 1.84 s sequential). Set `useWebWorkers: true` and it
|
||
parallelizes properly (0.47 s), landing right beside the others.
|
||
- **Browsers vary by engine.** Safari/WebKit runs `CompressionStream` on the main thread
|
||
(serial, like Deno), so use `useWebWorkers: true` there. Chromium implements it
|
||
separately and may behave differently — check a given browser by compressing several
|
||
large buffers through `CompressionStream` sequentially versus concurrently and comparing
|
||
the wall time.
|
||
|
||
**Rule of thumb:** on Node and Bun, concurrent `add()` is enough; on Deno and
|
||
Safari/WebKit, also set `useWebWorkers: true`. Web Workers are the portable way to get
|
||
this parallelism on any runtime — and the only way once you use a non-default level
|
||
(which switches to the WASM codec).
|
||
|
||
## Decompression
|
||
|
||
Level-6 archives, read back and fully materialized. archiver has no unzip API, so it is
|
||
excluded.
|
||
|
||
| Workload | @zip.js/zip.js | jszip | fflate |
|
||
|---|--:|--:|--:|
|
||
| Compressible text (20 MB) | **65 ms** | 143 ms | 82 ms |
|
||
| 5,000 files × ~2 KB | 565 ms | 453 ms | **89 ms** |
|
||
|
||
zip.js has the fastest large-stream decompression. On thousands of tiny entries the
|
||
per-entry setup cost dominates and **fflate is dramatically faster and lighter** — again
|
||
the right tool when you are unpacking many small files.
|
||
|
||
## Streaming a large file — 256 MB, disk → zip → disk
|
||
|
||
The input is streamed from disk and the archive is streamed back to disk; neither is
|
||
ever fully held in memory (for the libraries that support it).
|
||
|
||
| Library | Median time | Peak memory |
|
||
|---|--:|--:|
|
||
| archiver | 9183 ms | 111 MB |
|
||
| zip.js (workers) | 9893 ms | **99 MB** |
|
||
| zip.js (1 thread) | 10110 ms | **99 MB** |
|
||
| fflate | 10613 ms | 87 MB |
|
||
| jszip | 22358 ms | 527 MB |
|
||
|
||
zip.js, fflate and archiver all hold memory **flat** while streaming — zip.js peaks at
|
||
~99 MB regardless of the 256 MB input, thanks to real backpressure through the
|
||
compression pipeline. **jszip buffers the entire file** and needs ~527 MB, at more than
|
||
twice the wall-clock time. If you process files that do not fit comfortably in memory,
|
||
avoid jszip.
|
||
|
||
## Against native tooling — 7-Zip
|
||
|
||
How far is zip.js from a native archiver? [`benchmarks/bench-7z.js`](benchmarks/bench-7z.js)
|
||
compares it against the `7zz` CLI (7-Zip 25.01), disk-to-disk on both sides, under Deno
|
||
2.9.3 and Bun 1.3.14 (both runtimes agree within noise; Deno numbers shown). 7-Zip
|
||
timings include process spawn (~ms).
|
||
|
||
One comparison is impossible to make perfectly fair: **no 7-Zip preset runs zlib's
|
||
algorithm.** `-mx=5+` is a near-optimal parser (much slower, smaller output) and
|
||
`-mx=1..4` a greedy one (faster, larger output) — they bracket zlib level 6. So the
|
||
table carries two anchors: `-mx=6` as the *ratio* anchor and `-mx=1` as the *speed*
|
||
anchor. On this machine the `-mx` ladder on the 256 MB file reads: `-mx=1/3` → 5.9 s,
|
||
`-mx=5/6` → 34 s, `-mx=7` → 85 s — the 6× jump at `-mx=5` is the switch from greedy
|
||
matching to optimal parsing, and it cannot be hidden by threads (one file = one deflate
|
||
stream = one core).
|
||
|
||
| Workload (compress) | zip.js (workers) | 7-Zip `-mx=6` (mt) | 7-Zip `-mx=1` (mt) |
|
||
|---|--:|--:|--:|
|
||
| Compressible text (20 MB) | **426 ms** / 6.2 MB | 2606 ms / 5.8 MB | 470 ms / 6.8 MB |
|
||
| Incompressible data (20 MB) | **342 ms** | 410 ms | 372 ms |
|
||
| 5,000 files × ~2 KB | 780 ms / 5.5 MB | 199 ms / 4.9 MB | **168 ms** / 5.0 MB |
|
||
| Large file, disk-to-disk (256 MB) | **5.5 s** / 79.5 MB | 33.4 s / 73.6 MB | 5.9 s / 86.7 MB |
|
||
|
||
| Workload (decompress) | zip.js (workers) | 7-Zip (mt) |
|
||
|---|--:|--:|
|
||
| Compressible text (20 MB) | **69 ms** | 88 ms |
|
||
| 5,000 files × ~2 KB | 976 ms | **471 ms** |
|
||
|
||
- **On single-file streaming, zip.js strictly dominates 7-Zip's speed tier**: a bit
|
||
faster *and* ~9 % smaller output, because zlib-6's lazy matching is a better
|
||
speed/ratio point than 7-Zip's greedy fast mode. What `-mx=6` buys for its 6× time is
|
||
~7 % more compression — a trade zip.js cannot make, but also one most workloads don't
|
||
want.
|
||
- **7-Zip legitimately dominates many small files** (~4× on compress, ~2× on
|
||
decompress): its per-entry cost is near zero, while zip.js pays per-entry
|
||
orchestration (worker round trips, header writes, one output stream per extracted
|
||
file). Same lesson as the fflate rows above — tiny-entry workloads are zip.js's cost
|
||
center, and the codec backend is irrelevant there.
|
||
|
||
## The runtime's zlib decides zip.js throughput
|
||
|
||
On bulk data zip.js is a thin wrapper around the platform's `CompressionStream` — it
|
||
adds ~7 % over the raw encoder on the 256 MB stream. That means throughput is decided
|
||
by **which zlib the runtime vendors**, and they differ a lot. Same 256 MB compressible
|
||
file, one thread, level-6-class output everywhere:
|
||
|
||
| Encoder | Time | Output |
|
||
|---|--:|--:|
|
||
| zlib-ng — Deno & Bun `CompressionStream` | **4.8–5.1 s** | 79.4 MB |
|
||
| Chromium zlib — Node `CompressionStream` / `node:zlib` | 8.9 s | 78.3 MB |
|
||
| classic zlib — Apple `gzip -6` | 12.5 s | 78.9 MB |
|
||
| classic zlib compiled to WASM — zip.js `useCompressionStream: false` | ~16 s | 78.9 MB |
|
||
| (reference) 7-Zip `-mx=6` | 33.5 s | 73.6 MB |
|
||
|
||
Verified in the runtimes' sources: Deno builds `flate2` with vendored
|
||
**zlib-ng** (`__vendored_zlib_ng` default feature), Bun vendors **zlib-ng 2.3.3**
|
||
(SIMD CRC/adler/match kernels, NEON on arm64) behind `node:zlib` and
|
||
`CompressionStream`, and Node vendors **Chromium's zlib fork** (SIMD checksums and
|
||
hash sliding, but a match finder much closer to classic zlib).
|
||
|
||
Consequences worth knowing:
|
||
|
||
- **The same zip.js code runs ~1.75× faster on Deno/Bun than on Node** for bulk
|
||
compression. A "zip.js is fast/slow" measurement is often really a statement about
|
||
the host's zlib — and the Node tables above are the *pessimistic* end.
|
||
- **The WASM backend is the portability floor, not a peer**: classic zlib plus ~30 %
|
||
WebAssembly overhead ≈ 3× slower than native on compress. Decompression barely
|
||
suffers (inflate is cheap — within ~10 % of native). It still lands mid-curve
|
||
against 7-Zip: 16 s / 78.9 MB sits between `-mx=1` (5.9 s / 86.7 MB) and `-mx=6`
|
||
(33.5 s / 73.6 MB).
|
||
- Output sizes across the zlib family are interchangeable (78–79 MB): it is one
|
||
algorithm at four levels of implementation tuning. zip.js inherits whichever the
|
||
host provides — including future upgrades, for free.
|
||
|
||
## When to pick which
|
||
|
||
- **Choose zip.js** for the fastest compression of large or multiple entries
|
||
(parallelism with no Web Workers), the fastest large-stream decompression, flat
|
||
low-memory streaming of huge files, and the broadest ZIP feature set in one library —
|
||
AES & ZipCrypto encryption, Zip64, split/multi-volume archives, and an optional Web
|
||
Worker pool.
|
||
- **Choose fflate** when you deflate thousands of tiny buffers in a single call and want
|
||
the smallest memory footprint and the highest raw synchronous throughput.
|
||
- **archiver** is a solid streaming compressor on Node but cannot read archives.
|
||
- **jszip** is convenient but the slowest here and buffers whole files in memory.
|
||
|
||
## Reproduce
|
||
|
||
The harness lives in [`benchmarks/`](benchmarks/). It has no ties to the machine above;
|
||
run it on yours.
|
||
|
||
```sh
|
||
cd benchmarks
|
||
npm install # jszip, fflate, archiver (zip.js is used from the repo)
|
||
npm run corpus # generate the deterministic datasets under .corpus/
|
||
node bench.js # the head-to-head tables (compress / decompress / disk streaming)
|
||
node bench-backends.js # the parallelism & codec-backend matrix
|
||
deno run -A bench-7z.js # zip.js vs the 7zz CLI (also: bun bench-7z.js)
|
||
```
|
||
|
||
`bench-7z.js` needs the 7-Zip CLI (`brew install sevenzip`) and runs under Deno or Bun;
|
||
set `ZIPJS_BACKEND=wasm` to measure the WebAssembly codec instead of the native
|
||
`CompressionStream`, and `SKIP_HUGE=1` to skip the 256 MB combo.
|
||
|
||
Both scripts write JSON and a human-readable log to `benchmarks/results/`. Set
|
||
`RUNS=<n>` to change the number of repetitions (default 3). The datasets are generated
|
||
from a seeded PRNG (`benchmarks/lib/corpus.js`), so every run — and every library —
|
||
sees identical bytes.
|