@zip.js/zip.js 2.14.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/BENCHMARKS.md +350 -454
  2. package/deno.json +1 -1
  3. package/dist/zip-core-external.js +342 -120
  4. package/dist/zip-core-external.min.js +1 -1
  5. package/dist/zip-core.js +340 -113
  6. package/dist/zip-core.min.js +1 -1
  7. package/dist/zip-fs-core-external.js +342 -120
  8. package/dist/zip-fs-core-external.min.js +1 -1
  9. package/dist/zip-fs-core.js +340 -113
  10. package/dist/zip-fs-core.min.js +1 -1
  11. package/dist/zip-fs-external.js +342 -120
  12. package/dist/zip-fs-external.min.js +1 -1
  13. package/dist/zip-fs-native.js +342 -115
  14. package/dist/zip-fs-native.min.js +1 -1
  15. package/dist/zip-fs.js +346 -124
  16. package/dist/zip-fs.min.js +1 -1
  17. package/dist/zip-legacy.js +342 -115
  18. package/dist/zip-legacy.min.js +1 -1
  19. package/dist/zip-module.wasm +0 -0
  20. package/dist/zip-native.js +342 -115
  21. package/dist/zip-native.min.js +1 -1
  22. package/dist/zip-web-worker-native.js +1 -1
  23. package/dist/zip-web-worker.js +1 -1
  24. package/dist/zip.js +346 -124
  25. package/dist/zip.min.js +1 -1
  26. package/index-native.cjs +342 -115
  27. package/index-native.min.js +1 -1
  28. package/index.cjs +346 -124
  29. package/index.d.cts +20 -13
  30. package/index.d.ts +20 -13
  31. package/index.min.js +1 -1
  32. package/lib/core/codec-pool.js +35 -19
  33. package/lib/core/codec-worker.js +9 -6
  34. package/lib/core/streams/codecs/aes-hmac-sha1.js +194 -56
  35. package/lib/core/streams/zip-entry-stream.js +18 -10
  36. package/lib/core/streams/zlib-wasm/zlib-streams.js +2 -7
  37. package/lib/core/streams/zlib-wasm/zlib-streams.wasm +0 -0
  38. package/lib/core/util/decode-text.js +15 -1
  39. package/lib/core/version.js +1 -1
  40. package/lib/core/web-worker-inline-native.js +1 -1
  41. package/lib/core/web-worker-inline-wasm.js +1 -1
  42. package/lib/core/zip-reader.js +65 -16
  43. package/lib/core/zlib-streams-inline.js +1 -1
  44. package/package.json +1 -1
package/BENCHMARKS.md CHANGED
@@ -1,26 +1,21 @@
1
1
  # Benchmarks
2
2
 
3
- A fair, reproducible comparison of **@zip.js/zip.js** against
4
- [jszip](https://github.com/Stuk/jszip), [fflate](https://github.com/101arrowz/fflate)
5
- and [archiver](https://github.com/archiverjs/node-archiver) on a set of realistic
6
- workloads.
7
-
8
- The numbers below are honest: zip.js wins clearly on some workloads and loses on
9
- others. The goal is to show *where* each library is the right tool, and to give you a
10
- harness you can re-run on your own hardware — the results are machine-specific and you
11
- should not trust anyone's benchmark (including this one) without reproducing it.
12
-
13
- > **TL;DR** — For compressing large or multiple entries, zip.js is the fastest option
14
- > in the field, and it is the only one that parallelizes compression across CPU cores
15
- > **without spawning a single Web Worker** — it lets the platform's native
16
- > `CompressionStream` run on the threadpool while you simply issue concurrent `add()`
17
- > calls. It also streams arbitrarily large files at a flat, low memory ceiling. For
18
- > archives made of thousands of tiny entries, **fflate** is several times faster and much
19
- > lighter — but that gap is zip.js's per-entry orchestration, not its codec: measured on
20
- > their own, the codecs are the other way round (see [Codecs](#codecs-compared-at-equal-output-size)).
21
- > Against native tooling, zip.js on a modern runtime is a bit
22
- > faster *and* tighter than 7-Zip's fast mode on single-file streaming; 7-Zip keeps a
23
- > real edge only on thousands of tiny files.
3
+ Measurements of [@zip.js/zip.js](https://www.npmjs.com/package/@zip.js/zip.js) against
4
+ [jszip](https://github.com/Stuk/jszip), [fflate](https://github.com/101arrowz/fflate) and
5
+ [archiver](https://github.com/archiverjs/node-archiver), of zip.js's own codec backends, and of
6
+ the runtimes it runs on. Every number on this page comes from the harness in
7
+ [`benchmarks/`](benchmarks/), on the machine, date and versions below. The results are specific
8
+ to them; run the harness on your machine before relying on any of them.
9
+
10
+ The page answers four questions, each from one or two scripts of the harness:
11
+
12
+ - [How does zip.js compare with jszip, fflate and archiver?](#zipjs-against-jszip-fflate-and-archiver)
13
+ `bench.js`, on Node.
14
+ - [Which codec backend of zip.js is fastest, and what does concurrent `add()` buy?](#the-codec-backends-of-zipjs)
15
+ `bench-codecs.js` and `bench-backends.js`, on Node.
16
+ - [How do Node, Bun and Deno differ?](#node-bun-and-deno) `bench-runtimes.js`.
17
+ - [How fast is AES encryption?](#encryption-aes-256-on-a-stored-entry) `bench-aes.js` and
18
+ `bench-aes.html`, on the three runtimes and two browsers.
24
19
 
25
20
  ## Environment
26
21
 
@@ -28,477 +23,378 @@ should not trust anyone's benchmark (including this one) without reproducing it.
28
23
  |---|---|
29
24
  | Machine | Apple M2, 8 cores (4 performance + 4 efficiency), 16 GB RAM |
30
25
  | OS | macOS 26.6.2 (arm64) |
31
- | Runtime | Node.js v26.7.0 |
32
- | zip.js | 2.11.2 |
33
- | jszip | 3.10.1 |
26
+ | Runtimes | Node.js v26.7.0, Bun 1.4.2, Deno 2.9.6 |
27
+ | Browsers | Firefox 154, Chrome 153 (the browser encryption table only) |
28
+ | zip.js | 2.14.0 |
29
+ | jszip | 3.10.2 |
34
30
  | fflate | 0.8.3 |
35
31
  | archiver | 8.0.0 |
36
- | Measured | 2026-09-06 — the 7-Zip section under Deno 2.9.6, everything else on Node; the encryption section on 2026-09-09, also under Bun 1.4.2, Deno 2.9.6, Firefox 154 and Chrome 153 |
32
+ | Measured | 2026-09-10; the two encryption tables on 2026-09-12, on the engines that follow 2.14.0 |
37
33
 
38
34
  ## Method
39
35
 
40
- - **Isolation.** Each `(library, operation, workload)` combination runs in its own
41
- freshly-spawned Node process under `/usr/bin/time -l`, so there is no cross-library
42
- GC or heap contamination and peak memory is a true per-library figure.
43
- - **Timing.** `performance.now()` around the measured operation only. Every combination
44
- runs **3 times**; the table reports the **median**.
45
- - **Memory.** Peak resident set size (RSS) reported by `/usr/bin/time -l`. The "peak"
46
- column is the delta over an empty-process baseline (~50 MB on this runtime), i.e. the
47
- memory attributable to the work.
48
- - **Fair work.** All libraries compress at **DEFLATE level 6**, and output sizes are shown
49
- next to the times. Read them: a "level" is not a unit shared between libraries. zlib's
50
- level 6 and fflate's level 6 are different parameter sets, so the two do not produce the
51
- same amount of compression and a time is only comparable next to the size it achieved.
52
- The [Codecs](#codecs-compared-at-equal-output-size) section compares them by output size
53
- instead, which is the only axis on which "faster" means anything. The corpus is generated
54
- from a seeded PRNG, so every library sees byte-for-byte identical input.
55
- - **zip.js modes.** zip.js is measured both **single-threaded** (apples-to-apples with
56
- the single-threaded libraries) and with its **Web Worker** pool, so the worker
57
- overhead is never hidden.
58
-
59
- ## Compression — single process, single thread
60
-
61
- Level-6 DEFLATE, one entry (or one batch) compressed in the main thread. This is the
62
- apples-to-apples comparison against the single-threaded libraries.
63
-
64
- Time, with the size each library produced — the two are only meaningful together.
65
-
66
- | Workload | @zip.js/zip.js | jszip | fflate | archiver |
67
- |---|--:|--:|--:|--:|
68
- | Compressible text (20 MB) | 703 ms / 6.1 MB | 1714 ms / 6.2 MB | 908 ms / 6.3 MB | **702 ms** / 6.1 MB |
69
- | Incompressible data (20 MB) | 364 ms / 21.0 MB | 858 ms / 21.0 MB | **298 ms** / 21.0 MB | 357 ms / 21.0 MB |
70
- | Already-compressed media (20 MB) | 362 ms / 21.0 MB | 860 ms / 21.0 MB | **297 ms** / 21.0 MB | 356 ms / 21.0 MB |
71
- | 5,000 files × ~2 KB | 834 ms / 4.9 MB | 826 ms / 4.7 MB | **305 ms** / 4.8 MB | 431 ms / 4.8 MB |
36
+ - **Workloads.** Synthetic data from a seeded generator (`benchmarks/lib/corpus.js`), so every
37
+ library and every run sees the same bytes. "Text" is English-looking words that deflate
38
+ compresses about 3.4×, "incompressible" is random bytes, "5,000 files" is 5,000 text files of
39
+ about 2 KB each, and the 8 × 8 MB and 256 MB workloads are text.
40
+ - **Isolation.** In `bench.js` and `bench-backends.js`, each (library, operation, workload)
41
+ combination runs in its own freshly spawned Node process under macOS's `/usr/bin/time -l`, so
42
+ the libraries never share a heap and peak memory is per library. Every row of those two
43
+ scripts includes what a fresh process pays on its first operation: the JIT warm-up of each
44
+ library and, for the zip.js WebAssembly rows, the module instantiation, about 20 ms. The
45
+ codec, encryption and runtime scripts run in-process, with one warmup run before the timed
46
+ ones.
47
+ - **Timing.** `performance.now()` around the measured operation. Each combination runs 3 times
48
+ (5 in the encryption script) and the tables report the median. Differences of a few percent
49
+ are within run-to-run noise. A bold cell is the best number among the alternatives it is
50
+ compared with, one per column in the head-to-head tables and one per runtime in the runtime
51
+ table; it is not a verdict.
52
+ - **Memory.** Peak resident set size reported by `/usr/bin/time -l`, as a delta over an empty
53
+ Node process (about 50 MB). It is the highest of the 3 runs, while the time is their median.
54
+ - **Level.** Every library compresses at its own level 6. A level is not a unit shared between
55
+ libraries: zlib's level 6 and fflate's level 6 are different parameter sets and produce
56
+ different sizes, so every time is printed next to the size it achieved, and the
57
+ [Codecs](#codecs-at-equal-output-size) table compares by output size instead.
58
+ - **Codecs.** Every head-to-head table names the codec of each row. archiver runs Node's `zlib`
59
+ module, the host's zlib written in C; jszip (pako) and fflate deflate and inflate in
60
+ JavaScript. zip.js selects its codec at runtime, so it has one row per backend: the
61
+ `CompressionStream` and `DecompressionStream` of the runtime, Node's zlib here and the default
62
+ where they exist; the bundled WebAssembly zlib; and the pure-JavaScript zlib port. The last two
63
+ ship with the library, like the codecs of jszip and fflate, so those rows compare the
64
+ libraries without the host's zlib in the picture.
65
+ - **Checks.** No decompression row verifies the CRC-32 of the entries: zip.js and jszip leave
66
+ the check off by default, and fflate has none on read. Turning it on in zip.js adds one pass
67
+ over the output, about 15 ms per 20 MB.
68
+ - **Units.** MB in the tables is 10^6 bytes. The workload sizes (20 MB, 8 MB, 256 MB) and the
69
+ MB/s throughputs use 2^20 bytes.
70
+
71
+ ## zip.js against jszip, fflate and archiver
72
+
73
+ One Node process per measurement (`benchmarks/bench.js`), one thread, every library at its
74
+ level 6. zip.js appears once per codec backend.
75
+
76
+ ### Compression
77
+
78
+ One entry, or one batch of entries, compressed by a single sequence of calls. Time, with the
79
+ size each row produced:
80
+
81
+ | Library | Codec | Compressible text (20 MB) | Incompressible data (20 MB) | 5,000 files × ~2 KB |
82
+ |---|---|--:|--:|--:|
83
+ | @zip.js/zip.js | `CompressionStream` | **700 ms** / 6.1 MB | 365 ms / 21.0 MB | 888 ms / 4.9 MB |
84
+ | @zip.js/zip.js | WASM zlib | 1057 ms / 6.2 MB | 475 ms / 21.0 MB | 679 ms / 4.9 MB |
85
+ | @zip.js/zip.js | pure-JS zlib | 1238 ms / 6.2 MB | 802 ms / 21.0 MB | 1125 ms / 4.9 MB |
86
+ | jszip | pako (JavaScript) | 1723 ms / 6.2 MB | 862 ms / 21.0 MB | 843 ms / 4.7 MB |
87
+ | fflate | fflate (JavaScript) | 909 ms / 6.3 MB | **297 ms** / 21.0 MB | **281 ms** / 4.8 MB |
88
+ | archiver | Node `zlib` (C) | 702 ms / 6.1 MB | 359 ms / 21.0 MB | 424 ms / 4.8 MB |
72
89
 
73
90
  Peak memory for the same runs (Δ over baseline):
74
91
 
75
- | Workload | @zip.js/zip.js | jszip | fflate | archiver |
76
- |---|--:|--:|--:|--:|
77
- | Compressible text (20 MB) | 90 MB | 69 MB | **62 MB** | 72 MB |
78
- | Incompressible data (20 MB) | 153 MB | 92 MB | 108 MB | **81 MB** |
79
- | Already-compressed media (20 MB) | 154 MB | 93 MB | 108 MB | **74 MB** |
80
- | 5,000 files × ~2 KB | 252 MB | 267 MB | **102 MB** | 137 MB |
81
-
82
- On compressible text zip.js and archiver are tied (703 ms against 702 ms, inside the
83
- noise) and both produce the smallest archive; fflate is 29 % slower here *and* 3 % larger,
84
- which is the level-6-is-not-a-unit effect described above. On incompressible data — where
85
- no codec has anything to do — fflate wins on both axes. On 5,000 tiny files fflate is 2.7×
86
- faster than zip.js and uses a third of the memory; that gap is per-entry orchestration
87
- (worker round trips, header writes, one stream per entry), not codec speed, as the
88
- [Codecs](#codecs-compared-at-equal-output-size) section shows.
89
-
90
- ## Parallelism & codec backends — 8 files × 8 MB, single process
91
-
92
- This is the headline. The same 64 MB of compressible entries, compressed several ways
93
- in a **plain Node process** (no worker threads unless noted). zip.js can select its
94
- codec backend at runtime — native `CompressionStream`, the bundled WebAssembly zlib, or
95
- a pure-JavaScript zlib port — and it can issue `add()` calls concurrently.
92
+ | Library | Codec | Compressible text (20 MB) | Incompressible data (20 MB) | 5,000 files × ~2 KB |
93
+ |---|---|--:|--:|--:|
94
+ | @zip.js/zip.js | `CompressionStream` | 91 MB | 153 MB | 245 MB |
95
+ | @zip.js/zip.js | WASM zlib | 119 MB | 170 MB | 250 MB |
96
+ | @zip.js/zip.js | pure-JS zlib | 114 MB | 172 MB | 276 MB |
97
+ | jszip | pako (JavaScript) | 69 MB | 93 MB | 269 MB |
98
+ | fflate | fflate (JavaScript) | **62 MB** | 108 MB | **102 MB** |
99
+ | archiver | Node `zlib` (C) | 72 MB | **74 MB** | 137 MB |
100
+
101
+ On the text file zip.js with `CompressionStream` and archiver take the same time, 700 and
102
+ 702 ms, for the same output size: both run Node's zlib. With the codecs that ship with each
103
+ library, fflate takes 909 ms for 2 % more bytes, zip.js's WebAssembly zlib 1057 ms, its
104
+ pure-JavaScript port 1238 ms and jszip 1723 ms. On incompressible data fflate is the fastest row
105
+ and archiver the smallest in memory. On 5,000 small files the WebAssembly backend is 23 % faster
106
+ than `CompressionStream`, so a native stream costs more per entry than a WebAssembly one; fflate
107
+ takes 41 % of the WebAssembly row's time there against 86 % on the 20 MB file, and the
108
+ difference is the work zip.js does per entry, a stream and a header each. The zip.js rows use
109
+ the most memory on the two 20 MB files.
110
+
111
+ ### Decompression
96
112
 
97
- | Configuration | Median time | Output | vs jszip |
98
- |---|--:|--:|--:|
99
- | **zip.js — `CompressionStream`, concurrent `add()`** | **607 ms** | 19.6 MB | **9.3×** |
100
- | fflate — async (its own worker pool) | 733 ms | 20.0 MB | 7.7× |
101
- | zip.js — `CompressionStream`, sequential | 2214 ms | 19.6 MB | 2.6× |
102
- | archiver — Node zlib (libuv threadpool) | 2278 ms | 19.6 MB | 2.5× |
103
- | fflate — `zipSync` (single thread) | 3118 ms | 20.0 MB | 1.8× |
104
- | zip.js — WASM zlib | 3358 ms | 19.7 MB | 1.7× |
105
- | zip.js — pure-JS zlib | 3840 ms | 19.7 MB | 1.5× |
106
- | jszip (pako, single thread) | 5671 ms | 19.7 MB | 1.0× |
107
-
108
- The two fflate rows produce 20.0 MB where every other row produces 19.6–19.7 MB, i.e. ~2 %
109
- more, so they are not doing quite the same work as the rows above and below them.
110
-
111
- **The point:** zip.js with the native `CompressionStream` goes from **2214 ms
112
- sequential to 607 ms with concurrent `add()` — a 3.6× speedup — using no Web Workers at
113
- all.** The native codec runs on the platform's threadpool, so independent entries
114
- compress on multiple cores while your code stays on the main thread. That makes it the
115
- fastest configuration measured, ahead of fflate's dedicated worker pool.
116
-
117
- One honest caveat, visible in the table: **only the native `CompressionStream` backend
118
- parallelizes this way.** The WASM and pure-JS backends run synchronously on the main
119
- thread, so concurrent `add()` does not speed them up (3358 ms and 3840 ms whether
120
- sequential or "parallel"). Use those backends when a native `CompressionStream` is
121
- unavailable or when you need byte-identical zlib output; use the native backend when you
122
- want this parallelism.
123
-
124
- There is a second way to land on the WASM backend by accident: because
125
- `CompressionStream` exposes no level control, requesting any non-default compression
126
- level (e.g. `{ level: 5 }`) makes zip.js fall back to the WASM zlib codec — which, per
127
- the table, does not parallelize via concurrent `add()`. Keep the default level to keep
128
- the native-backend parallelism, or pair a custom level with Web Workers (below).
129
-
130
- That fallback costs more than a backend switch, and the
131
- [frontier](#the-frontier--one-axis-both-sides) measures how much: how much speed a lower
132
- level actually buys depends on how fast the host's own zlib is, and on Deno it buys
133
- nothing at all — `level: 5` there is slower *and* larger than the default.
134
-
135
- ### Parallelism is runtime-dependent
136
-
137
- The table above is measured on **Node**, and its "no Web Workers needed" result does
138
- **not** hold on every runtime: concurrent `add()` only spreads across cores if the
139
- runtime runs `CompressionStream` off the main thread. Same 8 × 8 MB workload, level 6,
140
- median of 3:
141
-
142
- | Runtime | sequential | concurrent `add()` | concurrent `add()` + `useWebWorkers` |
143
- |---|--:|--:|--:|
144
- | Node.js | 2.94 s | **0.74 s** | 0.74 s |
145
- | Bun | 1.80 s | **0.36 s** | 0.46 s |
146
- | Deno | 1.84 s | 1.82 s | **0.47 s** |
147
-
148
- - **Node and Bun** back `CompressionStream` with a threadpool, so concurrent `add()`
149
- alone parallelizes — no Web Workers needed (Bun is fastest here).
150
- - **Deno** runs `CompressionStream` on the isolate thread, so concurrent `add()` alone
151
- gives no speedup (1.82 s ≈ its 1.84 s sequential). Set `useWebWorkers: true` and it
152
- parallelizes properly (0.47 s), landing right beside the others.
153
- - **Browsers vary by engine.** Safari/WebKit runs `CompressionStream` on the main thread
154
- (serial, like Deno), so use `useWebWorkers: true` there. Chromium implements it
155
- separately and may behave differently — check a given browser by compressing several
156
- large buffers through `CompressionStream` sequentially versus concurrently and comparing
157
- the wall time.
158
-
159
- **Rule of thumb:** on Node and Bun, concurrent `add()` is enough; on Deno and
160
- Safari/WebKit, also set `useWebWorkers: true`. Web Workers are the portable way to get
161
- this parallelism on any runtime — and the only way once you use a non-default level
162
- (which switches to the WASM codec).
163
-
164
- ## Codecs, compared at equal output size
165
-
166
- The tables above compare *libraries* — a whole zip pipeline, at each library's default
167
- "level 6". This one compares only the **codecs**, on the same 20 MB buffer with no zip
168
- container around them, and sorts by output size rather than by level. That matters because
169
- zlib's level 6 and fflate's level 6 are different parameter sets: matched by level, the
170
- comparison silently reads a ratio difference as a speed difference.
171
-
172
- `frontier` marks a row that nothing smaller beats on time.
173
-
174
- | Codec | Setting | Output | Ratio | Time | Throughput | |
113
+ Level-6 archives, read back and fully materialized. archiver has no unzip API, so it is
114
+ excluded.
115
+
116
+ | Library | Codec | Compressible text (20 MB) | 5,000 files × ~2 KB |
117
+ |---|---|--:|--:|
118
+ | @zip.js/zip.js | `DecompressionStream` | **66 ms** | 628 ms |
119
+ | @zip.js/zip.js | WASM zlib | 78 ms | 476 ms |
120
+ | @zip.js/zip.js | pure-JS zlib | 180 ms | 615 ms |
121
+ | jszip | pako (JavaScript) | 137 ms | 444 ms |
122
+ | fflate | fflate (JavaScript) | 116 ms | **102 ms** |
123
+
124
+ Peak memory for the same runs (Δ over baseline):
125
+
126
+ | Library | Codec | Compressible text (20 MB) | 5,000 files × ~2 KB |
127
+ |---|---|--:|--:|
128
+ | @zip.js/zip.js | `DecompressionStream` | 116 MB | 350 MB |
129
+ | @zip.js/zip.js | WASM zlib | 163 MB | 484 MB |
130
+ | @zip.js/zip.js | pure-JS zlib | 154 MB | 451 MB |
131
+ | jszip | pako (JavaScript) | 103 MB | 306 MB |
132
+ | fflate | fflate (JavaScript) | **92 MB** | **120 MB** |
133
+
134
+ On the 20 MB stream `DecompressionStream` takes 66 ms and the WebAssembly inflate 78 ms, then
135
+ fflate 116 ms, jszip 137 ms and the pure-JavaScript port 180 ms. The WebAssembly row includes
136
+ the module instantiation a fresh process pays once, about 20 ms. Warm, the two inflates are
137
+ within 6 % of each other in the [Codecs](#codecs-at-equal-output-size) table. On 5,000 small
138
+ files the order reverses: fflate takes 102 ms, jszip 444 ms and the three zip.js rows 476 to
139
+ 628 ms, with `DecompressionStream` the slowest of them, the same per-entry cost as in
140
+ compression. fflate uses the least memory on both workloads, 34 % of the `DecompressionStream`
141
+ row on the small files, and the WebAssembly and pure-JavaScript rows peak higher than
142
+ `DecompressionStream`. The three zip.js rows allocate the same 800 MB of stream objects over the
143
+ 5,000 entries, about 160 KB per entry. The WebAssembly and pure-JavaScript codecs run on the
144
+ JavaScript thread, so V8's incremental marking keeps more of that garbage alive between
145
+ collections, and that is the difference between the rows.
146
+
147
+ ### Streaming a 256 MB file, disk to disk
148
+
149
+ One file read with `fs.createReadStream` in 64 KB chunks and one archive written with
150
+ `fs.createWriteStream`, one entry, each library's streaming API. zip.js takes Web Streams, so its
151
+ rows go through Node's `Readable.toWeb` and `Writable.toWeb` bridges; the other libraries take
152
+ the Node streams directly.
153
+
154
+ | Library | Codec | Median time | Peak memory (Δ) | Output |
155
+ |---|---|--:|--:|--:|
156
+ | @zip.js/zip.js | `CompressionStream` | **9055 ms** | 62 MB | 78.3 MB |
157
+ | archiver | Node `zlib` (C) | 9115 ms | 71 MB | 78.3 MB |
158
+ | fflate | fflate (JavaScript) | 12456 ms | **41 MB** | 80.2 MB |
159
+ | @zip.js/zip.js | WASM zlib | 13629 ms | 88 MB | 78.9 MB |
160
+ | @zip.js/zip.js | pure-JS zlib | 15485 ms | 120 MB | 78.9 MB |
161
+ | jszip | pako (JavaScript) | 22539 ms | 74 MB | 78.9 MB |
162
+
163
+ Every row streams: the peaks stay between 41 MB (fflate) and 120 MB (the pure-JavaScript port)
164
+ over baseline on the 256 MB input. zip.js with `CompressionStream` and archiver take the same
165
+ time within 1 %; jszip takes 2.5× that. With its WebAssembly zlib zip.js takes 1.5× the
166
+ `CompressionStream` time and 1.1× fflate's, for 1.6 % fewer bytes; the pure-JavaScript port
167
+ takes 1.2× fflate's time.
168
+
169
+ ## The codec backends of zip.js
170
+
171
+ zip.js selects its codec at runtime: the `CompressionStream` and `DecompressionStream` of the
172
+ host when they exist and the level is the default, otherwise the bundled WebAssembly zlib, and
173
+ the pure-JavaScript zlib port where WebAssembly cannot load or when it is configured. This
174
+ section measures them on Node, alone and then inside the zip pipeline.
175
+
176
+ ### Codecs at equal output size
177
+
178
+ The same 20 MB text buffer with no zip container around it (`benchmarks/bench-codecs.js`),
179
+ sorted by output size rather than by level, because zlib's level 6 and fflate's level 6 are
180
+ different parameter sets: matched by level, the comparison reads a ratio difference as a speed
181
+ difference.
182
+
183
+ A row is beaten when another row is at least as small and faster; the last column names the
184
+ fastest such row. A row with an empty last column is beaten by nothing on the table.
185
+
186
+ | Codec | Setting | Output | Ratio | Time | Throughput | Beaten by |
175
187
  |---|---|--:|--:|--:|--:|---|
176
- | WASM zlib | level 8 | 6,115,980 | 3.429 | 1466 ms | 13.6 MB/s | frontier |
177
- | `CompressionStream` | no level control | 6,120,503 | 3.426 | 692 ms | 28.9 MB/s | frontier |
178
- | WASM zlib | level 6 | 6,165,103 | 3.402 | 1023 ms | 19.5 MB/s | |
179
- | fflate | level 8 — its best | 6,239,025 | 3.361 | 750 ms | 26.7 MB/s | |
180
- | fflate | level 6 | 6,257,881 | 3.351 | 738 ms | 27.1 MB/s | |
181
- | WASM zlib | level 5 | 6,562,182 | 3.196 | 494 ms | 40.5 MB/s | frontier |
182
- | fflate | level 5 | 6,648,574 | 3.154 | 534 ms | 37.4 MB/s | |
183
- | WASM zlib | level 4 | 6,907,987 | 3.036 | 276 ms | 72.4 MB/s | frontier |
184
- | fflate | level 1 | 7,261,700 | 2.888 | 316 ms | 63.3 MB/s | |
185
- | WASM zlib | level 1 | 7,531,042 | 2.785 | 177 ms | 113.1 MB/s | frontier |
186
-
187
- The pure-JS port produces byte-identical output to the WASM codec at every level and is
188
- 1.2–2× slower; the full 28-row table is in `benchmarks/results/codecs-results.json`.
189
-
190
- Two things to take from it. **fflate never lands on the frontier here** — at every size it
191
- reaches, one of zip.js's codecs gets there sooner — and above ratio 3.361 it has no setting
192
- at all, while the WASM codec keeps going to 3.429. Second, the level numbers really are
193
- incomparable: fflate's level 6 falls between zlib's levels 5 and 6 on ratio, which is
194
- exactly why the library tables above show it as both faster *and* larger.
188
+ | WASM zlib | level 8 | 6,115,980 | 3.429 | 1504 ms | 13.3 MB/s | |
189
+ | `CompressionStream` | no level control | 6,120,503 | 3.426 | 716 ms | 27.9 MB/s | |
190
+ | WASM zlib | level 6 | 6,165,103 | 3.402 | 1047 ms | 19.1 MB/s | `CompressionStream` |
191
+ | fflate | level 8, its best | 6,239,025 | 3.361 | 767 ms | 26.1 MB/s | `CompressionStream` |
192
+ | fflate | level 6 | 6,257,881 | 3.351 | 815 ms | 24.6 MB/s | `CompressionStream` |
193
+ | WASM zlib | level 5 | 6,562,182 | 3.196 | 505 ms | 39.6 MB/s | |
194
+ | fflate | level 5 | 6,648,574 | 3.154 | 541 ms | 36.9 MB/s | WASM zlib level 5 |
195
+ | WASM zlib | level 4 | 6,907,987 | 3.036 | 278 ms | 71.9 MB/s | |
196
+ | fflate | level 1 | 7,261,700 | 2.888 | 318 ms | 62.9 MB/s | WASM zlib level 4 |
197
+ | WASM zlib | level 2 | 7,359,387 | 2.850 | 195 ms | 102.5 MB/s | |
198
+ | WASM zlib | level 1 | 7,531,042 | 2.785 | 175 ms | 114.1 MB/s | |
199
+
200
+ The pure-JavaScript port produces the same size as the WebAssembly codec at every level and
201
+ takes 1.0 to 1.8× its time, the gap closing at the higher levels; the full 28-row table is in
202
+ `benchmarks/results/codecs-results.json`.
203
+
204
+ Every fflate row is beaten by a zlib row: by the host's `CompressionStream` at fflate's levels 6
205
+ to 9, by the WebAssembly codec below. Above ratio 3.361 fflate has no setting, while the
206
+ WebAssembly codec reaches 3.429. fflate's level 6 sits between zlib's levels 5 and 6 on ratio,
207
+ which is why its output is larger than zip.js's in the library tables.
195
208
 
196
209
  Decompression of the same stream:
197
210
 
198
211
  | Codec | Time | Throughput |
199
212
  |---|--:|--:|
200
- | WASM zlib | **46 ms** | 434.7 MB/s |
201
- | `DecompressionStream` | 56 ms | 354.6 MB/s |
202
- | pure-JS zlib | 110 ms | 181.5 MB/s |
203
- | fflate | 123 ms | 162.7 MB/s |
213
+ | WASM zlib | **54 ms** | 369.5 MB/s |
214
+ | `DecompressionStream` | 57 ms | 352.6 MB/s |
215
+ | pure-JS zlib | 111 ms | 180.6 MB/s |
216
+ | fflate | 128 ms | 156.0 MB/s |
204
217
 
205
- The WASM inflate is 2.7× faster than fflate's, and faster than Node's own
206
- `DecompressionStream`, since the Chromium `inffast_chunk` port landed in 2.11.0.
218
+ The WebAssembly inflate and Node's `DecompressionStream` are within 6 % of each other; fflate's
219
+ inflate takes 2.4× the time of the WebAssembly one, the pure-JavaScript port 2.1×.
207
220
 
208
- ## Decompression
221
+ Alone, the WebAssembly backend deflates at level 6 in 1.46× the time of `CompressionStream` for
222
+ 0.7 % more bytes and inflates within 6 % of `DecompressionStream`. zip.js uses it by itself
223
+ when no `CompressionStream` exists or when a level other than the default is requested;
224
+ `useCompressionStream: false` selects it on every host, for an output that does not depend on
225
+ the host's zlib.
209
226
 
210
- Level-6 archives, read back and fully materialized. archiver has no unzip API, so it is
211
- excluded.
227
+ ### Concurrent `add()` and the backends
212
228
 
213
- | Workload | @zip.js/zip.js | jszip | fflate |
229
+ 8 files × 8 MB of text, 64 MB in one Node process (`benchmarks/bench-backends.js`), without
230
+ Web Workers unless noted. `add()` calls can be issued concurrently, and the table shows what
231
+ that buys with each backend.
232
+
233
+ | Configuration | Median time | Output | vs jszip |
214
234
  |---|--:|--:|--:|
215
- | Compressible text (20 MB) | **66 ms** | 133 ms | 117 ms |
216
- | 5,000 files × ~2 KB | 603 ms | 445 ms | **104 ms** |
235
+ | **zip.js — `CompressionStream`, concurrent `add()`** | **676 ms** | 19.6 MB | **8.6×** |
236
+ | fflate — async (its own worker pool) | 768 ms | 20.0 MB | 7.5× |
237
+ | archiver — Node zlib | 2324 ms | 19.6 MB | 2.5× |
238
+ | zip.js — `CompressionStream`, sequential `add()` | 2421 ms | 19.6 MB | 2.4× |
239
+ | fflate — `zipSync` (single thread) | 3165 ms | 20.0 MB | 1.8× |
240
+ | zip.js — WASM zlib, sequential `add()` | 3553 ms | 19.7 MB | 1.6× |
241
+ | zip.js — WASM zlib, concurrent `add()` | 3577 ms | 19.7 MB | 1.6× |
242
+ | zip.js — pure-JS zlib, concurrent `add()` | 3954 ms | 19.7 MB | 1.5× |
243
+ | zip.js — pure-JS zlib, sequential `add()` | 4008 ms | 19.7 MB | 1.4× |
244
+ | jszip (pako, single thread) | 5788 ms | 19.7 MB | 1.0× |
245
+
246
+ With the host's `CompressionStream`, zip.js goes from 2421 ms with sequential `add()` calls to
247
+ 676 ms with concurrent ones, 3.6×, without Web Workers: Node runs `CompressionStream` off the
248
+ main thread, so the entries compress on several cores while the JavaScript thread only feeds
249
+ them. fflate's async API, which runs its own worker pool, takes 768 ms on the same input; its
250
+ output is 2 % larger than the zlib rows, so it does slightly less work. The WebAssembly and
251
+ pure-JavaScript backends run on the main thread, and concurrent `add()` changes nothing for them.
252
+
253
+ Requesting a non-default level, e.g. `{ level: 5 }`, selects the bundled codec instead of
254
+ `CompressionStream`, which has no level control, and gives up this parallelism unless
255
+ `useWebWorkers` is set. The [Compression levels](#compression-levels) table shows what a lower
256
+ level buys on each runtime.
257
+
258
+ ## Node, Bun and Deno
259
+
260
+ The same zip.js code under the three runtimes, in-process, median of 3
261
+ (`benchmarks/bench-runtimes.js`). Browsers were not measured for these tables.
262
+
263
+ ### Concurrent `add()` per runtime
264
+
265
+ Concurrent `add()` spreads across cores only where the runtime runs `CompressionStream` off the
266
+ JavaScript thread. Same 8 × 8 MB workload as the backends table, level 6. That table spawns one
267
+ process per run and this one measures in-process after a warmup, which is why their Node numbers
268
+ differ:
269
+
270
+ | Runtime | sequential `add()` | concurrent `add()` | concurrent `add()`, `chunkSize` 256 KB | concurrent `add()` + `useWebWorkers` |
271
+ |---|--:|--:|--:|--:|
272
+ | Node.js v26.7.0 | 2.25 s | **0.59 s** | 0.59 s | 0.59 s |
273
+ | Bun 1.4.2 | 1.19 s | 1.19 s | **0.24 s** | 0.25 s |
274
+ | Deno 2.9.6 | 1.31 s | 1.31 s | 1.33 s | **0.34 s** |
275
+
276
+ - **Node** runs `CompressionStream` off the JavaScript thread, on the libuv threadpool, four
277
+ threads by default (`UV_THREADPOOL_SIZE`): concurrent `add()` alone is 3.8× faster than
278
+ sequential, and the chunk size changes nothing. Node has no `Worker` global, so
279
+ `useWebWorkers` spawns nothing there and the entries run in-process, in the same time.
280
+ - **Bun** runs `CompressionStream` off the JavaScript thread only for writes larger than 128 KB
281
+ (its native implementation, since Bun 1.4 in August 2026). zip.js writes 64 KB chunks by
282
+ default, so concurrent `add()` alone gains nothing on Bun; `chunkSize: 256 * 1024` makes it
283
+ 4.9× faster, and `useWebWorkers: true` 4.7×.
284
+ - **Deno** runs `CompressionStream` on the JavaScript thread whatever the write size: concurrent
285
+ `add()` alone gains nothing, `useWebWorkers: true` is 3.9× faster.
286
+
287
+ ### Compression levels
288
+
289
+ Level 6 runs the host's `CompressionStream`, every other level the bundled WebAssembly zlib, so
290
+ whether a lower level buys speed depends on how fast the host's zlib is. The pure-JavaScript
291
+ column runs the same JavaScript on each engine. One 20 MB text entry, one thread:
292
+
293
+ | Runtime | level 6, `CompressionStream` | level 5, WASM zlib | level 5, pure-JS zlib | level 1, WASM zlib |
294
+ |---|--:|--:|--:|--:|
295
+ | Node.js v26.7.0 | 686 ms / 6.12 MB | 521 ms / 6.56 MB | 780 ms / 6.56 MB | 192 ms / 7.53 MB |
296
+ | Bun 1.4.2 | 371 ms / 6.20 MB | 464 ms / 6.56 MB | 665 ms / 6.56 MB | 168 ms / 7.53 MB |
297
+ | Deno 2.9.6 | 403 ms / 6.21 MB | 590 ms / 6.56 MB | 689 ms / 6.56 MB | 228 ms / 7.53 MB |
217
298
 
218
- zip.js has the fastest large-stream decompression. On thousands of tiny entries the
219
- per-entry setup cost dominates and **fflate is dramatically faster and lighter** — again
220
- the right tool when you are unpacking many small files.
299
+ On Node, level 5 on the WebAssembly codec is 24 % faster than the default for 7 % more bytes.
300
+ On Bun and Deno the default is already faster than level 5, so level 5 there is slower and
301
+ larger; only level 1 buys speed on them, 55 % on Bun and 44 % on Deno, at 21 % more bytes. The
302
+ pure-JavaScript port at level 5 takes 1.5× the WebAssembly time on Node, 1.4× on Bun and 1.2×
303
+ on Deno, and is slower than the default on every runtime. The two bundled codecs produce the
304
+ same size on every runtime; the level-6 sizes differ because they come from three different
305
+ zlib builds.
221
306
 
222
- ## Encryption — AES-256, stored entry
307
+ ### One 256 MB stream
308
+
309
+ On bulk data zip.js adds almost nothing over the host's `CompressionStream`, so its throughput is
310
+ that of the zlib the runtime ships, and they differ. One 256 MB text file, one thread, in
311
+ memory, fed in 64 KB writes as zip.js does; gzip is Apple's, timed as a child process:
312
+
313
+ | Runtime | `CompressionStream` alone | zip.js, one entry | Output |
314
+ |---|--:|--:|--:|
315
+ | Node.js v26.7.0 | 8.99 s | 8.78 s | 78.3 MB |
316
+ | Bun 1.4.2 | 4.73 s | 4.77 s | 79.4 MB |
317
+ | Deno 2.9.6 | 5.13 s | 5.23 s | 79.5 MB |
318
+ | Apple `gzip -6`, classic zlib | 12.3 to 12.4 s | | 78.9 MB |
319
+
320
+ zip.js is within 2 % of the raw stream on every runtime. On this file Bun's `CompressionStream`
321
+ takes 53 % of Node's time and Deno's 57 %, for about 1.5 % more bytes; Apple's gzip takes 1.4×
322
+ Node's time. Which zlib each runtime ships is not verified here; the table measures the result.
323
+
324
+ ## Encryption: AES-256 on a stored entry
223
325
 
224
326
  An AES entry pays the cipher on every byte, so this section measures the engines zip.js can run
225
- the WinZip cipher on (AES-CTR with the Gladman counter, HMAC-SHA1) against the sjcl code they
226
- replaced after 2.13.1. The entry is stored (level 0) so no codec sits in the pipeline, and the
227
- "before" row is the 2.13.1 bundle measured by the same script. 20 MB of incompressible data,
228
- in-process, single thread, median of 5; the two numbers are one archive written, then read back.
327
+ the WinZip cipher on (AES-CTR with the counter WinZip specifies, HMAC-SHA1) against the sjcl code
328
+ they replaced after 2.13.1. The entry is stored (level 0) so no codec sits in the pipeline, and
329
+ the "before" row is the 2.13.1 bundle measured by the same script. 20 MB of incompressible data,
330
+ in-process, single thread, median of 5 (`benchmarks/bench-aes.js`); the two numbers of a cell are
331
+ one archive written, then read back.
229
332
 
230
333
  | Engine | Node.js | Bun | Deno |
231
334
  |---|--:|--:|--:|
232
- | WebAssembly, linked into the module of the WebAssembly builds | **111 / 111 MB/s** | **115 / 115** | 99 / 100 |
233
- | JavaScript, the fallback of those builds and the engine of the native and core builds | 91 / 86 | 102 / 102 | **109 / 103** |
234
- | 2.13.1, sjcl | 26 / 25 | 29 / 29 | 19 / 19 |
335
+ | WebAssembly, linked into the module of the WebAssembly builds | **131 / 130 MB/s** | **118 / 121** | 123 / 122 |
336
+ | JavaScript, the fallback of those builds and the engine of the native and core builds | 114 / 114 | 112 / 111 | **142 / 129** |
337
+ | 2.13.1, sjcl | 25 / 25 | 27 / 27 | 19 / 19 |
235
338
 
236
- The same entry at 32 MB in a browser, through the Web Worker pool, median of 3 passes
237
- ([`benchmarks/bench-aes.html`](benchmarks/bench-aes.html)):
339
+ The same measurement in a browser, through the Web Worker pool, on 32 MB of bytes generated in
340
+ the page (the corpus is not served to the browser), median of 3 passes
341
+ ([`benchmarks/bench-aes.html`](benchmarks/bench-aes.html), which prints its result and writes no
342
+ file):
238
343
 
239
344
  | Engine | Firefox 154 | Chrome 153 |
240
345
  |---|--:|--:|
241
- | WebAssembly | **87 / 89 MB/s** | **99 / 101** |
242
- | JavaScript | 55 / 59 | 77 / 77 |
346
+ | WebAssembly | **96 / 101 MB/s** | **118 / 121** |
347
+ | JavaScript | 68 / 69 | 100 / 98 |
243
348
  | 2.13.1, sjcl | 17 / 17 | 22 / 22 |
244
349
 
245
- What to take from it:
246
-
247
- - **The JavaScript engine alone is 3.5× sjcl on Node and 3 to 4× in browsers.** sjcl ran the
248
- cipher 16 bytes at a time through a generic bit-array layer; the new engine works on typed
249
- arrays and is fed whole chunks. Every build has it.
250
- - **The WebAssembly kernel is the same code in C, and it is flat across hosts: 100 to 115 MB/s
251
- everywhere.** What varies is the JavaScript engine, within 20 % of the kernel either way on
252
- Node, Bun and Deno, whose JITs run typed-array code about as fast as wasm, and 1.3 to 1.6×
253
- slower in browsers (Safari 26.6, measured at the engine level only, sits with Chrome at 1.3×).
254
- The WebAssembly builds use the kernel whenever their module loads and fall back to the
255
- JavaScript engine when it cannot, for instance under a Content Security Policy without
256
- `'wasm-unsafe-eval'`, which is why the fallback stays in the bundle.
257
- - **Neither is hardware AES, and nothing in a browser can be.** OpenSSL on this machine encrypts
258
- AES-256-CTR at 5.8 GB/s with the ARMv8 instructions and at 245 MB/s with them disabled
259
- (`OPENSSL_armcap=0`). WebAssembly has no access to them, and Web Crypto cannot run this counter
260
- mode: it increments the last byte where WinZip increments the first, and it offers no ECB to
261
- build the keystream from. Software AES in the 100 to 120 MB/s range is the ceiling until a
262
- platform API exposes the instructions.
263
- - The kernel costs the default bundle 2.1 KB gzipped; the native and core builds carry only the
264
- JavaScript engine and grew by 0.1 KB.
265
-
266
- ## Streaming a large file — 256 MB, disk → zip → disk
267
-
268
- The input is streamed from disk and the archive is streamed back to disk; neither is
269
- ever fully held in memory (for the libraries that support it).
270
-
271
- | Library | Median time | Peak memory (Δ) | Output |
272
- |---|--:|--:|--:|
273
- | zip.js (workers) | **8888 ms** | 60 MB | 78.3 MB |
274
- | archiver | 9150 ms | 71 MB | 78.3 MB |
275
- | zip.js (1 thread) | 9296 ms | 62 MB | 78.3 MB |
276
- | fflate | 12549 ms | **43 MB** | 80.2 MB |
277
- | jszip | 22014 ms | 484 MB | 78.9 MB |
278
-
279
- zip.js, fflate and archiver all hold memory **flat** while streaming — zip.js peaks ~60 MB
280
- over baseline regardless of the 256 MB input, thanks to real backpressure through the
281
- compression pipeline. **jszip buffers the entire file** and needs ~484 MB, at more than
282
- twice the wall-clock time. If you process files that do not fit comfortably in memory,
283
- avoid jszip.
284
-
285
- ## Against native tooling — 7-Zip
286
-
287
- How far is zip.js from a native archiver? [`benchmarks/bench-7z.js`](benchmarks/bench-7z.js)
288
- compares it against the `7zz` CLI (7-Zip 25.01), disk-to-disk on both sides, under Deno
289
- 2.9.6. 7-Zip timings include process spawn (~ms).
290
-
291
- **No 7-Zip preset runs zlib's algorithm**, so `-mx=6` and level 6 are not the same request
292
- and a table pairing them by digit is measuring two things at once. This section therefore
293
- has two parts: the defaults against each other, and then a frontier that varies *one* axis,
294
- the compression level, on **both** sides at a fixed core budget.
295
-
296
- ### Defaults against defaults
297
-
298
- Level 6 and `-mx=6`, i.e. what each tool does when you do not tune it. Every contender the
299
- harness measures is listed; time / output size.
300
-
301
- | Workload (compress) | zip.js (1 thread) | zip.js (workers) | 7-Zip (1 thread) | 7-Zip `-mx=6` (mt) | 7-Zip `-mx=1` (mt) |
302
- |---|--:|--:|--:|--:|--:|
303
- | Compressible text (20 MB) | **418 ms** / 6.2 MB | 428 ms / 6.2 MB | — | 2540 ms / 5.8 MB | 444 ms / 6.8 MB |
304
- | Incompressible data (20 MB) | **321 ms** / 21.0 MB | 333 ms / 21.0 MB | — | 418 ms / 21.0 MB | 353 ms / 21.0 MB |
305
- | 8 files × 8 MB | 1308 ms / 19.9 MB | 308 ms / 19.9 MB | 8102 ms / 18.4 MB | 1510 ms / 18.4 MB | **294 ms** / 21.7 MB |
306
- | 5,000 files × ~2 KB | 1245 ms / 5.2 MB | 825 ms / 5.2 MB | 580 ms / 4.9 MB | 193 ms / 4.9 MB | **173 ms** / 5.0 MB |
307
- | Large file, disk-to-disk (256 MB) | **5.4 s** / 79.5 MB | 5.5 s / 79.5 MB | — | 33.4 s / 73.6 MB | 5.8 s / 86.7 MB |
308
-
309
- | Workload (decompress) | zip.js (1 thread) | zip.js (workers) | 7-Zip (1 thread) | 7-Zip (mt) |
310
- |---|--:|--:|--:|--:|
311
- | Compressible text (20 MB) | 83 ms | **61 ms** | — | 81 ms |
312
- | 8 files × 8 MB | 181 ms | **83 ms** | 241 ms | 247 ms |
313
- | 5,000 files × ~2 KB | 989 ms | 906 ms | 491 ms | **462 ms** |
314
-
315
- `7-Zip (1 thread)` is only listed where it means something: a single file is one deflate
316
- stream, so `-mmt` changes nothing there and the two 7-Zip columns would measure the same
317
- run. That is also why zip.js's worker pool does nothing on the single-file rows — the
318
- parallelism both tools have is *between* entries, never inside one.
319
-
320
- ### The frontier — one axis, both sides
321
-
322
- Levels 1–9 on the zip.js side against `-mx=1,3,5,6,7,9` on the 7-Zip side, sorted by output
323
- size. A tool is faster than another only where it is faster **at the same size**, and
324
- `frontier` marks a row that nothing smaller beats on time.
325
-
326
- **One thread, one deflate stream on both sides** — 20 MB of text:
327
-
328
- | Encoder | Output | Ratio | Time | |
329
- |---|--:|--:|--:|---|
330
- | 7-Zip `-mx=9` | 5.7 MB | 3.665 | 14889 ms | frontier |
331
- | 7-Zip `-mx=7` | 5.7 MB | 3.665 | 6643 ms | frontier |
332
- | 7-Zip `-mx=5` | 5.8 MB | 3.646 | 2597 ms | frontier |
333
- | 7-Zip `-mx=6` | 5.8 MB | 3.646 | 2605 ms | |
334
- | zip.js level 8 | 6.1 MB | 3.429 | 1537 ms | frontier |
335
- | zip.js level 9 | 6.1 MB | 3.429 | 1544 ms | |
336
- | zip.js level 7 | 6.1 MB | 3.421 | 1305 ms | frontier |
337
- | **zip.js level 6 (default)** | 6.2 MB | 3.377 | **418 ms** | frontier |
338
- | zip.js level 5 | 6.6 MB | 3.196 | 590 ms | |
339
- | 7-Zip `-mx=1` | 6.8 MB | 3.096 | 458 ms | |
340
- | 7-Zip `-mx=3` | 6.8 MB | 3.096 | 453 ms | |
341
- | zip.js level 4 | 6.9 MB | 3.036 | 358 ms | frontier |
342
- | zip.js level 3 | 7.0 MB | 2.975 | 420 ms | |
343
- | zip.js level 2 | 7.4 MB | 2.850 | 266 ms | frontier |
344
- | zip.js level 1 | 7.5 MB | 2.785 | 239 ms | frontier |
345
-
346
- **The same 20 MB, one thread, but on Node** — because the zip.js side of that table is not
347
- one codec. Level 6 is the host's `CompressionStream`, so it changes with the runtime; every
348
- other level is the bundled WASM codec, which does not (its output is byte-identical on both
349
- and its times agree within 5 %):
350
-
351
- | Encoder | Output | Ratio | Time | |
352
- |---|--:|--:|--:|---|
353
- | 7-Zip `-mx=9` | 5.7 MB | 3.665 | 14904 ms | frontier |
354
- | 7-Zip `-mx=7` | 5.7 MB | 3.665 | 6548 ms | frontier |
355
- | 7-Zip `-mx=5` | 5.8 MB | 3.646 | 2535 ms | frontier |
356
- | 7-Zip `-mx=6` | 5.8 MB | 3.646 | 2553 ms | |
357
- | zip.js level 8 | 6.1 MB | 3.429 | 1474 ms | frontier |
358
- | zip.js level 9 | 6.1 MB | 3.429 | 1476 ms | |
359
- | **zip.js level 6 (default)** | 6.1 MB | 3.426 | **696 ms** | frontier |
360
- | zip.js level 7 | 6.1 MB | 3.421 | 1238 ms | |
361
- | zip.js level 5 | 6.6 MB | 3.196 | 514 ms | frontier |
362
- | 7-Zip `-mx=1` | 6.8 MB | 3.096 | 447 ms | frontier |
363
- | 7-Zip `-mx=3` | 6.8 MB | 3.096 | 445 ms | frontier |
364
- | zip.js level 4 | 6.9 MB | 3.036 | 304 ms | frontier |
365
- | zip.js level 3 | 7.0 MB | 2.975 | 375 ms | |
366
- | zip.js level 2 | 7.4 MB | 2.850 | 221 ms | frontier |
367
- | zip.js level 1 | 7.5 MB | 2.785 | 200 ms | frontier |
368
-
369
- **Every core, both sides** — 8 files × 8 MB, on Deno. It has to be Deno: Node exposes no
370
- global `Worker`, so `useWebWorkers` cannot spawn one there and only the default level —
371
- which rides the platform threadpool instead — parallelizes at all. Measured on Node the
372
- same table reads 617 ms at level 6 against 4090 ms at level 7, i.e. the WASM levels run
373
- serially, which would make it a comparison of 7-Zip on 8 cores against zip.js on 1.
374
-
375
- | Encoder | Output | Ratio | Time | |
376
- |---|--:|--:|--:|---|
377
- | 7-Zip `-mx=9` | 18.3 MB | 3.660 | 8625 ms | frontier |
378
- | 7-Zip `-mx=7` | 18.3 MB | 3.660 | 3796 ms | frontier |
379
- | 7-Zip `-mx=5` | 18.4 MB | 3.645 | 1510 ms | frontier |
380
- | 7-Zip `-mx=6` | 18.4 MB | 3.645 | 1540 ms | |
381
- | zip.js level 9 | 19.6 MB | 3.428 | 971 ms | frontier |
382
- | zip.js level 8 | 19.6 MB | 3.428 | 991 ms | |
383
- | zip.js level 7 | 19.6 MB | 3.420 | 852 ms | frontier |
384
- | **zip.js level 6 (default)** | 19.9 MB | 3.376 | **317 ms** | frontier |
385
- | zip.js level 5 | 21.0 MB | 3.196 | 405 ms | |
386
- | 7-Zip `-mx=3` | 21.7 MB | 3.096 | 309 ms | frontier |
387
- | 7-Zip `-mx=1` | 21.7 MB | 3.096 | 313 ms | |
388
- | zip.js level 4 | 22.1 MB | 3.037 | 287 ms | frontier |
389
- | zip.js level 3 | 22.6 MB | 2.975 | 316 ms | |
390
- | zip.js level 2 | 23.6 MB | 2.849 | 239 ms | frontier |
391
- | zip.js level 1 | 24.1 MB | 2.784 | 218 ms | frontier |
392
-
393
- What the two curves say:
394
-
395
- - **zip.js's default is a spike on its own curve, because it is a different codec.** The
396
- platform's `CompressionStream` exposes no level control, so **level 6 runs the host's
397
- native zlib and every other level drops onto the bundled WASM one**. How big the spike is
398
- therefore depends on whose zlib the host ships: 418 ms at ratio 3.377 on Deno's zlib-ng,
399
- 696 ms at 3.426 on Node's Chromium zlib — ~1.7× faster and ~1.4 % looser.
400
- - **So whether a lower level buys anything is runtime-dependent, and on Deno it does not.**
401
- On Node, `level: 5` costs 514 ms against the default's 696 ms: the ordinary trade, 26 %
402
- faster for 7 % more bytes. On Deno the default is already 418 ms, so the same `level: 5`
403
- takes 590 ms and produces a *larger* archive — **slower and bigger, strictly worse**. If
404
- you reach for a lower level to go faster, measure it on your runtime first.
405
- - **One level is a bad deal everywhere: `level: 3` loses to `level: 4`** on both time and
406
- size (375 ms / 7.0 MB against 304 ms / 6.9 MB on Node; 420 ms against 358 ms on Deno).
407
- Both are the same WASM codec, so this one is zlib's own curve, not a fallback artifact.
408
- - **7-Zip's nine presets are three encoders.** `-mx=1` and `-mx=3` are identical, so are
409
- `-mx=5`/`-mx=6` and `-mx=7`/`-mx=9` — **`-mx=9` costs 2.3× the time of `-mx=7` for
410
- byte-identical output**. The 5.7× jump from `-mx=3` to `-mx=5` is greedy matching giving
411
- way to optimal parsing, and threads cannot hide it.
412
- - **Against the greedy tier zip.js wins outright.** Single-threaded it is 418 ms / 6.2 MB
413
- against 453–458 ms / 6.8 MB: faster *and* 9 % smaller than both `-mx=1` and `-mx=3`. On
414
- all cores it ties them on time (317 ms against 309 ms) and is still 8 % smaller.
415
- - **Against the optimal-parsing tier it has nothing**, at any level. 7-Zip's cheapest route
416
- to 3.6 costs 1510 ms where zip.js's best is 971 ms at 3.43; buying that last 6 % of ratio
417
- costs 4.8× the default's time. That is a real limit of DEFLATE-as-zlib-writes-it, not a
418
- tuning gap.
419
- - **Threading buys the two sides different amounts**: 4.2× for zip.js on 8 × 8 MB
420
- (1308 → 308 ms) against 5.4× for 7-Zip (8102 → 1510 ms). zip.js closes most of that gap
421
- because it starts from a much faster single-threaded number.
422
- - **7-Zip legitimately dominates many small files** (~4× on compress, ~2× on decompress):
423
- its per-entry cost is near zero, while zip.js pays per-entry orchestration. Same lesson
424
- as the fflate rows above — tiny-entry workloads are zip.js's cost center, and the codec
425
- is irrelevant there. Note the reverse on 8 × 8 MB decompression, where entries are large
426
- enough for the pool to pay off: 83 ms against 247 ms, zip.js ~3× faster.
427
-
428
- ## The runtime's zlib decides zip.js throughput
429
-
430
- On bulk data zip.js is a thin wrapper around the platform's `CompressionStream` — it
431
- adds ~7 % over the raw encoder on the 256 MB stream. That means throughput is decided
432
- by **which zlib the runtime vendors**, and they differ a lot. Same 256 MB compressible
433
- file, one thread, level-6-class output everywhere. This is the one table on this page
434
- still carrying its July 2026 measurement: it compares runtimes to each other rather than
435
- zip.js to anything, so it ages with their zlib and not with ours.
436
-
437
- | Encoder | Time | Output |
438
- |---|--:|--:|
439
- | zlib-ng — Deno & Bun `CompressionStream` | **4.8–5.1 s** | 79.4 MB |
440
- | Chromium zlib — Node `CompressionStream` / `node:zlib` | 8.9 s | 78.3 MB |
441
- | classic zlib — Apple `gzip -6` | 12.5 s | 78.9 MB |
442
- | (reference) 7-Zip `-mx=6` | 33.5 s | 73.6 MB |
443
-
444
- Verified in the runtimes' sources: Deno builds `flate2` with vendored
445
- **zlib-ng** (`__vendored_zlib_ng` default feature), Bun vendors **zlib-ng 2.3.3**
446
- (SIMD CRC/adler/match kernels, NEON on arm64) behind `node:zlib` and
447
- `CompressionStream`, and Node vendors **Chromium's zlib fork** (SIMD checksums and
448
- hash sliding, but a match finder much closer to classic zlib).
449
-
450
- Consequences worth knowing:
451
-
452
- - **The same zip.js code runs ~1.75× faster on Deno/Bun than on Node** for bulk
453
- compression. A "zip.js is fast/slow" measurement is often really a statement about
454
- the host's zlib — and the Node tables above are the *pessimistic* end.
455
- - **The WASM backend is a fallback, but a much closer one since 2.11.0.** It used to be
456
- classic zlib plus WebAssembly overhead, ~3× slower than native on compress. It now
457
- carries Chromium's zlib: on the codec table above it deflates at level 6 in 1023 ms
458
- against the native `CompressionStream`'s 692 ms — 1.5× slower for 0.7 % more bytes — and
459
- it *inflates faster* than the native `DecompressionStream` (46 ms against 56 ms). Reach
460
- for it when no native `CompressionStream` exists, when you need a specific level, or
461
- when you need output that does not vary with the host's zlib.
462
- - Output sizes across the zlib family are interchangeable (78–79 MB): it is one
463
- algorithm at four levels of implementation tuning. zip.js inherits whichever the
464
- host provides — including future upgrades, for free.
465
-
466
- ## When to pick which
467
-
468
- - **Choose zip.js** for the fastest compression of large or multiple entries
469
- (parallelism with no Web Workers), the fastest large-stream decompression, flat
470
- low-memory streaming of huge files, and the broadest ZIP feature set in one library —
471
- AES & ZipCrypto encryption, Zip64, split/multi-volume archives, and an optional Web
472
- Worker pool.
473
- - **Choose fflate** for archives of thousands of tiny entries, and when you want the
474
- smallest memory footprint and the smallest bundle. Note what it does *not* buy you: at
475
- equal output size its codec is not the fastest one here (see
476
- [Codecs](#codecs-compared-at-equal-output-size)), so the win is fflate's very low
477
- per-entry cost, not its deflate.
478
- - **archiver** is a solid streaming compressor on Node but cannot read archives.
479
- - **jszip** is convenient but the slowest here and buffers whole files in memory.
350
+ - The JavaScript engine is 4.0 to 4.6× sjcl on Node, Bun and in the two browsers, and 6.8 to
351
+ 7.5× on Deno, where sjcl was slowest. sjcl ran the cipher 16 bytes
352
+ at a time through a bit-array layer; the new engine works on typed arrays and is fed whole
353
+ chunks, with the AES rounds and the SHA-1 steps written out. Every build has it.
354
+ - The WebAssembly kernel is the same C code on every host, with the same rounds written out:
355
+ 118 to 131 MB/s on Node, Bun and Deno, 96 to 121 MB/s in the two browsers. The JavaScript
356
+ engine is within 13 % of it on Node and Bun, ahead of it on Deno, and 1.2 to 1.4× slower in
357
+ the browsers. The WebAssembly builds use
358
+ the kernel whenever their module loads and fall back to the JavaScript engine when it cannot,
359
+ for instance under a Content Security Policy that does not allow WebAssembly
360
+ (`'wasm-unsafe-eval'`).
361
+ - Neither is hardware AES. OpenSSL on this machine (`openssl speed -evp aes-256-ctr`, 8 KB
362
+ blocks) runs AES-256-CTR at 9.4 GB/s with the ARMv8 AES instructions and at
363
+ 249 MB/s with them disabled (`OPENSSL_armcap=0`). WebAssembly has no access to those
364
+ instructions, and Web Crypto cannot run this counter mode: it increments the last byte of the
365
+ counter where WinZip increments the first, and it exposes no ECB to build the keystream from.
366
+ - The bundles did not grow with the new engines. Gzipped, `dist/zip.min.js` went from 67,869
367
+ bytes in 2.13.1 to 67,820 in 2.14.0, `dist/zip-native.min.js` from 73,798 to 72,060 and
368
+ `dist/zip-core.min.js` from 36,405 to 35,493 (`git show <tag>:<file> | gzip -9 | wc -c`):
369
+ sjcl's removal outweighs the JavaScript engine and the kernel. Writing the rounds out after
370
+ 2.14.0 gives some of it back: 71,238, 73,339 and 36,235 bytes for the three files behind the
371
+ encryption tables above.
480
372
 
481
373
  ## Reproduce
482
374
 
483
- The harness lives in [`benchmarks/`](benchmarks/). It has no ties to the machine above;
484
- run it on yours.
375
+ The harness lives in [`benchmarks/`](benchmarks/). `bench.js` and `bench-backends.js` need macOS
376
+ (`/usr/bin/time -l` for peak memory) and Node; the other scripts run wherever zip.js runs.
485
377
 
486
378
  ```sh
487
379
  cd benchmarks
488
- npm install # jszip, fflate, archiver (zip.js is used from the repo)
489
- npm run corpus # generate the deterministic datasets under .corpus/
490
- node bench.js # the head-to-head tables (compress / decompress / disk streaming)
491
- node bench-backends.js # the parallelism & codec-backend matrix
492
- node bench-codecs.js # the codecs alone, sorted by output size
493
- node bench-aes.js # the AES engines on a stored entry (also: bun / deno run -A); bench-aes.html is the browser page
494
- deno run -A bench-7z.js # zip.js vs the 7zz CLI (also: bun bench-7z.js)
380
+ npm install # jszip, fflate, archiver (zip.js is used from the repository)
381
+ npm run corpus # generate the datasets under .corpus/
382
+ node bench.js # the head-to-head tables: compress, decompress, disk streaming
383
+ node bench-backends.js # the concurrent add() and backends table
384
+ node bench-codecs.js # the codecs alone, sorted by output size
385
+ node bench-aes.js # the AES engines; also: bun bench-aes.js, deno run -A bench-aes.js
386
+ node bench-runtimes.js # the runtime tables; also: bun bench-runtimes.js, deno run -A bench-runtimes.js
495
387
  ```
496
388
 
497
- `bench-7z.js` needs the 7-Zip CLI (`brew install sevenzip`) and runs under Deno or Bun;
498
- set `ZIPJS_BACKEND=wasm` to measure the WebAssembly codec instead of the native
499
- `CompressionStream`, and `SKIP_HUGE=1` to skip the 256 MB combo.
500
-
501
- Both scripts write JSON and a human-readable log to `benchmarks/results/`. Set
502
- `RUNS=<n>` to change the number of repetitions (default 3). The datasets are generated
503
- from a seeded PRNG (`benchmarks/lib/corpus.js`), so every run — and every library —
504
- sees identical bytes.
389
+ Each script writes its JSON to `benchmarks/results/`; the `*-run.log` files there are the
390
+ console output of the runs behind this page. `RUNS=<n>` sets the number of repetitions (default
391
+ 3, 5 in `bench-aes.js`). `ZIPJS_BUNDLE=<path to a previous index.min.js> node bench-aes.js` adds
392
+ the row of a previous release; `git show v2.13.1:index.min.js` gave the sjcl row. The browser
393
+ encryption table comes from `benchmarks/bench-aes.html`, served from the repository root
394
+ (`npx http-server -p 8080`, then `http://localhost:8080/benchmarks/bench-aes.html`), with
395
+ `?engine=js` for the JavaScript engine and `?bundle=zipjs-2.13.1.min.js` for a bundle copied next
396
+ to the page; it prints the median of its three passes and a JSON line.
397
+
398
+ Two more scripts feed no table on this page: `bench-crc.js` compares the two ways zip.js can
399
+ obtain the CRC-32 of an entry compressed by `CompressionStream`, and `bench-7z.js` compares
400
+ zip.js with the 7-Zip command line (`brew install sevenzip`, runs under Deno or Bun).