Rust-powered universal compression for JavaScript/TypeScript. zstd, gzip, brotli, and lz4 in one package.
- Why comprs?
- Comparison with Alternatives
- Installation
- Quick Start
- Choosing an Algorithm
- API
- Supported Algorithms
- Platform Support
- Browser Usage
- Migration
- Benchmarks
- Contributing
The JavaScript compression ecosystem is fragmented across 12+ packages with inconsistent APIs and mixed maintenance status. comprs consolidates this into a single, fast, well-typed library:
- Native performance — Rust core compiled via napi-rs, with a WebAssembly build for browsers
- Unified API — Same interface for zstd, gzip, brotli, and lz4
- Streaming — Web Streams API (
TransformStream) for processing large data with bounded memory, in Node.js and browsers, except LZ4 decompression and brotli-dictionary compression streams, which buffer the whole input (see Notes) - Universal — Node.js, Deno, and Bun (native), and browsers (WebAssembly)
- Zero JS dependencies — Only Rust and the platform
- Interactive playground — Try any algorithm live in your browser, no install needed
| Feature | comprs | pako | fflate | node:zlib |
|---|---|---|---|---|
| zstd | ✅ | ❌ | ❌ | |
| gzip/deflate | ✅ | ✅ | ✅ | ✅ |
| brotli | ✅ | ❌ | ❌ | ✅ |
| lz4 | ✅ | ❌ | ❌ | ❌ |
| Web Streams API | ✅ | ❌ | ❌ | ❌ |
| Node.js Transform | ✅ | ❌ | ❌ | ✅ |
| Streaming | ✅ | Chunked† | ✅ | ✅ |
| Browser | ✅ | ✅ | ✅ | ❌ |
| Deno/Bun | ✅ | ✅ | ✅ | ✅‡ |
| Native performance | ✅ | ❌ | ❌ | ✅ |
| TypeScript | ✅ | ✅ | ✅ | ✅ |
| Dictionary | zstd, brotli | deflate | deflate | deflate; zstd (Node.js 22.19+, 24.6+) |
| Zero JS deps | ✅ | ✅ | ✅ | ✅ |
* node:zlib zstd support requires Node.js ≥ 22.15 and is experimental
† pako uses chunked Inflate/Deflate classes, not the Web Streams API
‡ Deno 2 and Bun implement node:zlib
npm install @derodero24/comprs
# or
pnpm add @derodero24/comprs
# or
yarn add @derodero24/comprs
# or
bun add @derodero24/comprsTry it live → derodero24.github.io/comprs
import { zstdCompress, zstdDecompress } from '@derodero24/comprs';
const data = Buffer.from('Hello, comprs!');
const compressed = zstdCompress(data);
const decompressed = zstdDecompress(compressed);All algorithms use the same pattern:
import { gzipCompress, brotliCompress, lz4Compress } from '@derodero24/comprs';
const gzipped = gzipCompress(data); // gzip
const brotlied = brotliCompress(data); // brotli
const lz4ed = lz4Compress(data); // lz4import { createGzipCompressStream } from '@derodero24/comprs/streams';
const response = await fetch('https://example.com/data.json');
if (!response.body) throw new Error('Response has no body');
// Pipe through a compression TransformStream
const compressed = response.body.pipeThrough(createGzipCompressStream());import { createGzipCompressTransform } from '@derodero24/comprs/node';
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
await pipeline(
createReadStream('input.txt'),
createGzipCompressTransform(),
createWriteStream('output.gz'),
);import { decompress } from '@derodero24/comprs';
// Works with any supported format — no need to know the algorithm
const decompressed = decompress(compressedData);
// Limit the decompressed size (default: 256 MB)
const limited = decompress(compressedData, 10 * 1024 * 1024);zstd, gzip and LZ4 are recognized by their magic numbers. Brotli has none, so it is recognized heuristically, by decoding up to the first 64 KiB of the data: a truncated brotli stream, or other data that happens to pass for brotli, makes decompress() throw the same error as data of unknown format. The auto-detecting streams buffer up to 64 KiB of input to detect the format. Raw deflate cannot be detected: use deflateDecompress() for it.
import { gzipCompressAsync, gzipDecompressAsync } from '@derodero24/comprs';
// In Node.js, runs on the libuv thread pool — keeps the event loop free
const compressed = await gzipCompressAsync(largeData);
const decompressed = await gzipDecompressAsync(compressed);import { zstdCompress } from '@derodero24/comprs';
zstdCompress(data, 1); // fast compression
zstdCompress(data); // default (level 3)
zstdCompress(data, 22); // best compression
zstdCompress(data, -1); // fast mode with negative levelsimport { zstdTrainDictionary, zstdCompressWithDict, zstdDecompressWithDict } from '@derodero24/comprs';
// Train from samples of similar data
const dict = zstdTrainDictionary(samples);
// Compress/decompress with dictionary
const compressed = zstdCompressWithDict(data, dict);
const decompressed = zstdDecompressWithDict(compressed, dict);// Brotli dictionary (no training step — provide raw dictionary bytes)
import { brotliCompressWithDict, brotliDecompressWithDict } from '@derodero24/comprs';
const dict = Buffer.from('{"id":0,"name":"","email":"@example.com"}'.repeat(10));
const compressed = brotliCompressWithDict(data, dict);
const decompressed = brotliDecompressWithDict(compressed, dict);// Deno
import { gzipCompress } from 'npm:@derodero24/comprs';
// Bun (same as Node.js)
import { gzipCompress } from '@derodero24/comprs';Both runtimes load the native addon, as Node.js does. Deno needs permission for it: run with --allow-ffi --allow-read --allow-env, or --allow-all. Without --allow-ffi, the import fails with a misleading Cannot find native binding error, which blames a bug in npm. Deno also needs a local node_modules directory, which holds the platform package with the binary: set "nodeModulesDir": "auto" in deno.json, or install the package into a local node_modules with npm.
| Use case | Recommended | Why |
|---|---|---|
| Web asset delivery (CDN, HTTP) | brotli | Best compression ratio; native browser Accept-Encoding support |
| General-purpose file compression | zstd | Fastest all-round with excellent compression ratio |
| Real-time data / logging / IPC | lz4 | Lowest latency; optimized for speed over ratio |
| Legacy systems / maximum compatibility | gzip | Universal support; every tool and platform can decompress |
| Many small similar records (JSON, logs) | zstd + dictionary | Dictionary pre-seeds the compressor with expected patterns |
| Brotli with domain-specific data | brotli + dictionary | Custom dictionary for repeated structures without training |
| Mode | When to use |
|---|---|
Sync (zstdCompress) |
Small data (< 1 MB), low-latency requirements, scripts |
Async (zstdCompressAsync) |
Large data or when the Node.js event loop must stay free (servers); browsers run them on the calling thread |
Streaming (createZstdCompressStream) |
Unknown/unbounded data size, memory-constrained environments |
Dictionary (zstdCompressWithDict) |
Compressing many small, structurally similar items |
The Web Streams and Node.js Transforms process each chunk synchronously on the calling thread, so the event loop is blocked while a chunk is compressed or decompressed. node:zlib streams use the libuv thread pool instead. For large inputs where event-loop latency matters, use the *Async one-shot functions or a worker thread.
| Function | Description |
|---|---|
zstdCompress(data, level?) |
Compress with zstd. Level: -131072 to 22 (default: 3) |
zstdDecompress(data) |
Decompress zstd data (max 256 MB output) |
zstdDecompressWithCapacity(data, capacity) |
Decompress with explicit output size limit |
| Function | Description |
|---|---|
gzipCompress(data, level?) |
Compress with gzip. Level: 0-9 (default: 6) |
gzipCompressWithHeader(data, header, level?) |
Compress with custom gzip header (filename, mtime) |
gzipReadHeader(data) |
Read gzip header metadata without decompressing |
gzipDecompress(data) |
Decompress gzip data |
gzipDecompressWithCapacity(data, capacity) |
Decompress with explicit output size limit |
deflateCompress(data, level?) |
Compress with raw deflate. Level: 0-9 (default: 6) |
deflateDecompress(data) |
Decompress raw deflate data |
deflateDecompressWithCapacity(data, capacity) |
Decompress with explicit output size limit |
| Function | Description |
|---|---|
brotliCompress(data, quality?) |
Compress with brotli. Quality: 0-11 (default: 6) |
brotliDecompress(data) |
Decompress brotli data (max 256 MB output) |
brotliDecompressWithCapacity(data, capacity) |
Decompress with explicit output size limit |
| Function | Description |
|---|---|
lz4Compress(data) |
Compress with LZ4 frame format, with a content checksum |
lz4Decompress(data) |
Decompress LZ4 data, including concatenated, skippable and legacy frames (max 256 MB output) |
lz4DecompressWithCapacity(data, capacity) |
Decompress with explicit output size limit |
| Function | Description |
|---|---|
decompress(data, maxOutputSize?) |
Auto-detect format and decompress (zstd, gzip, brotli, lz4). maxOutputSize limits the output (default: 256 MB) |
detectFormat(data) |
Detect compression format. Returns 'zstd', 'gzip', 'brotli', 'lz4', or 'unknown' |
| Function | Description |
|---|---|
crc32(data, initialValue?) |
Compute CRC32 checksum (supports incremental computation) |
version() |
Returns the library version |
Dictionary API
| Function | Description |
|---|---|
zstdTrainDictionary(samples, maxDictSize?) |
Train a dictionary from sample data (default max: 110 KB, limit: 16 MB) |
zstdCompressWithDict(data, dict, level?) |
Compress with pre-trained dictionary |
zstdDecompressWithDict(data, dict) |
Decompress dictionary-compressed data |
zstdDecompressWithDictWithCapacity(data, dict, capacity) |
Decompress with dictionary and explicit output size limit |
| Function | Description |
|---|---|
brotliCompressWithDict(data, dict, quality?) |
Compress with custom dictionary |
brotliDecompressWithDict(data, dict) |
Decompress dictionary-compressed data |
brotliDecompressWithDictWithCapacity(data, dict, capacity) |
Decompress with explicit capacity |
All one-shot functions have async variants that run on the libuv thread pool. Append Async to any function name. In browsers, they run on the calling thread instead (see Browser Usage):
const compressed = await zstdCompressAsync(data, level);
const decompressed = await gzipDecompressAsync(compressed);They report every error through the returned Promise, invalid arguments included: they reject with the error that the synchronous function throws for the same arguments and never throw themselves, so .catch() or await inside try handles all of them.
Full async API list
| Function | Description |
|---|---|
zstdCompressAsync(data, level?) |
Async zstd compression |
zstdDecompressAsync(data) |
Async zstd decompression |
zstdDecompressWithCapacityAsync(data, capacity) |
Async zstd decompression with explicit size limit |
zstdCompressWithDictAsync(data, dict, level?) |
Async zstd compression with dictionary |
zstdDecompressWithDictAsync(data, dict) |
Async zstd decompression with dictionary |
zstdDecompressWithDictWithCapacityAsync(data, dict, capacity) |
Async zstd decompression with dictionary and size limit |
zstdTrainDictionaryAsync(samples, maxDictSize?) |
Async dictionary training |
gzipCompressAsync(data, level?) |
Async gzip compression |
gzipDecompressAsync(data) |
Async gzip decompression |
gzipDecompressWithCapacityAsync(data, capacity) |
Async gzip decompression with explicit size limit |
deflateCompressAsync(data, level?) |
Async deflate compression |
deflateDecompressAsync(data) |
Async deflate decompression |
deflateDecompressWithCapacityAsync(data, capacity) |
Async deflate decompression with explicit size limit |
brotliCompressAsync(data, quality?) |
Async brotli compression |
brotliDecompressAsync(data) |
Async brotli decompression |
brotliDecompressWithCapacityAsync(data, capacity) |
Async brotli decompression with explicit size limit |
brotliCompressWithDictAsync(data, dict, quality?) |
Async brotli compression with dictionary |
brotliDecompressWithDictAsync(data, dict) |
Async brotli decompression with dictionary |
brotliDecompressWithDictWithCapacityAsync(data, dict, capacity) |
Async brotli decompression with dictionary and size limit |
lz4CompressAsync(data) |
Async LZ4 compression |
lz4DecompressAsync(data) |
Async LZ4 decompression |
lz4DecompressWithCapacityAsync(data, capacity) |
Async LZ4 decompression with explicit size limit |
decompressAsync(data, maxOutputSize?) |
Async auto-detect format and decompress, with an optional output size limit |
The *Async functions copy their input (data, dictionary or training samples) on the calling thread before they hand the work to the thread pool, and keep no reference to it: changing or transferring the input once the call has returned is safe and does not affect the result. The copy blocks the event loop for about 0.6 ms per MB; for very large inputs, call the functions from a worker thread.
Web Streams API (TransformStream) for all algorithms, in Node.js, Deno, Bun and browsers. Import from @derodero24/comprs/streams:
import { createGzipCompressStream } from '@derodero24/comprs/streams';The package root re-exports the stream helpers for import only, and not in browsers (see Browser Usage); @derodero24/comprs/streams works with both import and require().
These streams accept the chunks that CompressionStream accepts, any ArrayBuffer or ArrayBufferView (a DataView, a Uint16Array, ...), as well as a SharedArrayBuffer, and read them byte for byte, both with the native addon (Node.js, Deno and Bun) and in the browser build.
Full streaming API list
| Function | Description |
|---|---|
createZstdCompressStream(level?) |
Create a zstd compression TransformStream |
createZstdDecompressStream(maxOutputSize?) |
Create a zstd decompression TransformStream |
createGzipCompressStream(level?) |
Create a gzip compression TransformStream |
createGzipDecompressStream(maxOutputSize?) |
Create a gzip decompression TransformStream |
createDeflateCompressStream(level?) |
Create a raw deflate compression TransformStream |
createDeflateDecompressStream(maxOutputSize?) |
Create a raw deflate decompression TransformStream |
createBrotliCompressStream(quality?) |
Create a brotli compression TransformStream |
createBrotliDecompressStream(maxOutputSize?) |
Create a brotli decompression TransformStream |
createLz4CompressStream() |
Create an LZ4 compression TransformStream |
createLz4DecompressStream(maxOutputSize?) |
Create an LZ4 decompression TransformStream |
createZstdCompressDictStream(dict, level?) |
Streaming zstd compression with dictionary |
createZstdDecompressDictStream(dict, maxOutputSize?) |
Streaming zstd decompression with dictionary |
createBrotliCompressDictStream(dict, quality?) |
Streaming brotli compression with dictionary |
createBrotliDecompressDictStream(dict, maxOutputSize?) |
Streaming brotli decompression with dictionary |
createDecompressStream(maxOutputSize?) |
Auto-detect format and create a decompression TransformStream |
For Node.js stream.pipeline() compatibility, import from @derodero24/comprs/node. This subpath is built on node:stream, so it does not work in browsers:
import { createGzipCompressTransform } from '@derodero24/comprs/node';
import { pipeline } from 'node:stream/promises';
import { createReadStream, createWriteStream } from 'node:fs';
await pipeline(
createReadStream('input.txt'),
createGzipCompressTransform(),
createWriteStream('output.gz'),
);The transforms push their output in chunks of at most readableHighWaterMark bytes (64 KiB by default), even when a single small input chunk decompresses to many megabytes.
Full Node.js Transform API list
| Function | Description |
|---|---|
createZstdCompressTransform(level?) |
Node.js Transform for zstd compression |
createZstdDecompressTransform(maxOutputSize?) |
Node.js Transform for zstd decompression |
createGzipCompressTransform(level?) |
Node.js Transform for gzip compression |
createGzipDecompressTransform(maxOutputSize?) |
Node.js Transform for gzip decompression |
createDeflateCompressTransform(level?) |
Node.js Transform for deflate compression |
createDeflateDecompressTransform(maxOutputSize?) |
Node.js Transform for deflate decompression |
createBrotliCompressTransform(quality?) |
Node.js Transform for brotli compression |
createBrotliDecompressTransform(maxOutputSize?) |
Node.js Transform for brotli decompression |
createLz4CompressTransform() |
Node.js Transform for LZ4 compression |
createLz4DecompressTransform(maxOutputSize?) |
Node.js Transform for LZ4 decompression |
createZstdCompressDictTransform(dict, level?) |
Node.js Transform for zstd dict compression |
createZstdDecompressDictTransform(dict, maxOutputSize?) |
Node.js Transform for zstd dict decompression |
createBrotliCompressDictTransform(dict, quality?) |
Node.js Transform for brotli dict compression |
createBrotliDecompressDictTransform(dict, maxOutputSize?) |
Node.js Transform for brotli dict decompression |
createDecompressTransform(maxOutputSize?) |
Auto-detect format and create a decompression Transform |
| Algorithm | One-shot | Streaming | Status |
|---|---|---|---|
| zstd | ✅ | ✅ | Available |
| gzip / deflate | ✅ | ✅ | Available |
| brotli | ✅ | ✅ | Available |
| lz4 | ✅ | ✅ | Available |
| Platform | Backend | Status |
|---|---|---|
| Node.js ≥ 22 | Native (napi-rs) | ✅ |
| Deno | Native (napi-rs) | ✅ |
| Bun | Native (napi-rs) | ✅ |
| Browsers | WASM (wasm-bindgen) | ✅ |
Node.js, Deno, and Bun load a prebuilt native binary, which the package manager installs as an optional dependency on the platforms listed under Build targets. In browsers, comprs uses its WebAssembly build instead (see Browser Usage).
On any other platform, loading comprs in Node.js, Deno, or Bun throws Cannot find native binding: the WebAssembly build is not used as a fallback there. The error's cause chain lists every file and package the loader tried, and the innermost one names the platform, such as Unsupported OS: aix, architecture: ppc64 or ./comprs.linux-riscv64-gnu.node. On a listed platform, the same error means that the binary package was not installed, for example because optional dependencies were omitted. The chain also names @derodero24/comprs-wasm32-wasi: the loader that napi-rs generates still looks for this WASI build, which is no longer published (2.0.2 was its last version). Do not install it, as a newer comprs would load that outdated build.
| OS | Architectures |
|---|---|
| macOS | Intel (x64), Apple Silicon (ARM64) |
| Linux | x64, ARM64 (glibc & musl) |
| Windows | x64, ARM64 |
| WASM | wasm32-unknown-unknown (wasm-bindgen) |
The Linux glibc binaries need glibc 2.17 or newer. The Windows binaries link the C runtime statically, so they do not need the Visual C++ Redistributable.
The browser WASM binary (wasm32-unknown-unknown) is built with wasm-pack, optimized for size (opt-level = "s"). It contains all four codecs.
comprs-wasm_bg.wasm |
Size |
|---|---|
| Raw | 1.87 MB |
| gzip (level 9) | 792 KB |
| brotli (quality 11) | 545 KB |
The compressed sizes are those of Node.js's zlib; what a CDN serves depends on its compressor and level. CI reports these sizes on every pull request, and fails when the raw or gzip size grows over its budget.
Browser builds that import @derodero24/comprs get its WebAssembly build, through the browser condition of the package's exports. Import the functions and call them; there is no initialization function to call:
import { gzipCompress, gzipDecompress } from '@derodero24/comprs';
const data = new TextEncoder().encode('Hello from the browser!');
const compressed = gzipCompress(data);
const decompressed = gzipDecompress(compressed);The entry module fetches and instantiates the WebAssembly binary with top-level await when it is imported, so every function works once the import has resolved, and a failed download rejects the import. It locates the binary, browser/comprs-wasm_bg.wasm in the package, with new URL('./comprs-wasm_bg.wasm', import.meta.url), a pattern that webpack and Vite turn into an emitted asset; with other tools, copy the binary next to the bundle. The bundler must also support top-level await:
| Tool | What it needs |
|---|---|
| Vite 8 | Nothing, for vite build and vite dev. |
| Vite 7 and older | vite dev needs optimizeDeps: { exclude: ['@derodero24/comprs'] }, as the dependency pre-bundling of these versions breaks the URL of the binary. Before Vite 7, the default build target does not support top-level await: set build.target: 'es2022' or later. |
| webpack 5 | Nothing: it enables top-level await by default since 5.83 and emits the binary as an asset. |
| esbuild | --format=esm and a --target that supports top-level await (the default, esnext, does). esbuild leaves new URL(…) as it is, so copy node_modules/@derodero24/comprs/browser/comprs-wasm_bg.wasm next to the bundle. |
| No bundler | Serve the package's browser/ directory, and map the package name to its entry with an import map: <script type="importmap">{ "imports": { "@derodero24/comprs": "/node_modules/@derodero24/comprs/browser/index.js", "@derodero24/comprs/streams": "/node_modules/@derodero24/comprs/browser/streams.js" } }</script> |
To import comprs in a web worker that Vite bundles, also set worker: { format: 'es' } and create the worker with { type: 'module' }: Vite's default worker format, 'iife', does not support top-level await.
Serve .wasm files as application/wasm, which lets the browser compile the binary while it downloads; with another type, it falls back to slower compilation and logs a warning.
The Web Streams helpers of @derodero24/comprs/streams have a browser build as well, on the same WebAssembly module. Import them from that subpath: unlike the ES module entry of Node.js, the browser entry does not re-export them.
import { createGzipDecompressStream } from '@derodero24/comprs/streams';
const response = await fetch('/data.json.gz');
if (!response.body) throw new Error('Response has no body');
const json = await new Response(response.body.pipeThrough(createGzipDecompressStream())).json();The WebAssembly build has the one-shot functions, their *Async variants and the streaming contexts (GzipCompressContext and the like). They take the same arguments as those of the native addon, and return the same values, except that:
- functions return
Uint8Arrayrather thanBuffer, anddetectFormat()returns a plain string; - the
*Asyncfunctions do not run on another thread. Each one runs its synchronous function on the calling thread before it returns, and returns a Promise of the result: they keep code written for the native addon working, but block the page as long as the synchronous call. To keep a page responsive while it compresses large data, use comprs in a Web Worker. Every error, including an invalid argument, rejects the Promise and none is thrown, as with the native functions; - the streaming contexts keep their state in WebAssembly memory, which garbage collection frees, and do not report it to the engine. As on Node.js,
close()and[Symbol.dispose]()release that state at once; in addition,free()frees the context object itself. A closed or freed context throws when it is used. The streams of@derodero24/comprs/streamsfree their context as soon as they end or fail, and when they are cancelled, where the runtime calls thecancel()method of their transformer; - a panic, which aborts the native addon, makes the WebAssembly build throw
RuntimeError: unreachable, after it logs the panic message withconsole.error().
As in Node.js, the streams work in bounded memory, except LZ4 decompression and brotli dictionary compression, which hold their whole input until it ends. The WebAssembly memory grows to the most that the module has used at once, and does not shrink.
Its declarations, browser/index.d.ts and browser/streams.d.ts, list what it exports; TypeScript uses them only when it resolves the browser condition ("customConditions": ["browser"] in tsconfig.json), and the Node.js declarations otherwise. The browser condition applies to import only: require() cannot load a module that uses top-level await, so require('@derodero24/comprs') and require('@derodero24/comprs/streams') load the native addon, also in test runners that set the condition, such as Jest with a jsdom environment. Under Jest's ES module support, that environment imports the WebAssembly build, which does not load in Jest: set testEnvironmentOptions: { customExportConditions: ['node', 'node-addons'] } to get the native addon. The @derodero24/comprs/node subpath is for Node.js only: it loads the native addon and node:stream, so it does not work in browsers.
Native modules need to be externalized in SSR frameworks:
Next.js
// next.config.js
const nextConfig = {
serverExternalPackages: ['@derodero24/comprs'],
};Vite SSR
// vite.config.js
export default {
ssr: {
external: ['@derodero24/comprs'],
},
};Client bundles resolve the browser condition and get the WebAssembly build, with the bundler setup described above.
- import pako from 'pako';
- const compressed = pako.gzip(data);
- const decompressed = pako.ungzip(compressed);
+ import { gzipCompress, gzipDecompress } from '@derodero24/comprs';
+ const compressed = gzipCompress(data);
+ const decompressed = gzipDecompress(compressed);- import { gzipSync, gunzipSync } from 'fflate';
- const compressed = gzipSync(data);
- const decompressed = gunzipSync(compressed);
+ import { gzipCompress, gzipDecompress } from '@derodero24/comprs';
+ const compressed = gzipCompress(data);
+ const decompressed = gzipDecompress(compressed);comprs adds zstd, lz4, brotli, dictionary compression, and Web Streams API — none of which are available in fflate.
- import { gzipSync, gunzipSync } from 'node:zlib';
- const compressed = gzipSync(data);
- const decompressed = gunzipSync(compressed);
+ import { gzipCompress, gzipDecompress } from '@derodero24/comprs';
+ const compressed = gzipCompress(data);
+ const decompressed = gzipDecompress(compressed);The tables and charts below were measured in March 2026, before comprs 1.0, on an Apple M2 with Node.js 22, with each library at its default level. They are being regenerated by a script, with equal levels and compression ratios (#556). Run the benchmarks locally with pnpm run bench. Numbers vary by machine and data type.
comprs uses a pure-Rust brotli encoder: at equal quality, it is slower than node:zlib's C encoder, especially for small inputs. Prefer zstd when speed matters.
gzip: comprs vs pako vs fflate vs node:zlib
Compression (ops/sec, higher is better)
| Size | comprs | pako | fflate | node:zlib |
|---|---|---|---|---|
| 150B patterned | 1,352 | 1,578 | 7,785 | 13,078 |
| 10KB patterned | 3,605 | 133 | 345 | 1,878 |
| 1MB patterned | 246 | 14 | 11 | 100 |
| 150B random | 29,914 | 4,653 | 7,471 | 1,423 |
| 10KB random | 400 | 42 | 652 | 3,459 |
| 1MB random | 13 | 5 | 6 | 10 |
Decompression (ops/sec, higher is better)
| Size | comprs | pako | fflate | node:zlib |
|---|---|---|---|---|
| 150B patterned | 162,220 | 65,141 | 521,933 | 308,493 |
| 10KB patterned | 95,300 | 19,235 | 46,402 | 102,585 |
| 1MB patterned | 903 | 123 | 310 | 1,451 |
| 150B random | 29,040 | 1,952 | 560,840 | 140,243 |
| 10KB random | 7,004 | 17,616 | 407,789 | 271,245 |
| 1MB random | 1,508 | 278 | 19,341 | 4,282 |
deflate: comprs vs pako vs fflate vs node:zlib
Compression (ops/sec, higher is better)
| Size | comprs | pako | fflate | node:zlib |
|---|---|---|---|---|
| 150B patterned | 106,183 | 13,666 | 55,434 | 4,107 |
| 10KB patterned | 2,746 | 5,160 | 3,218 | 7,161 |
| 1MB patterned | 1,963 | 91 | 217 | 472 |
| 150B random | 56,537 | 18,763 | 51,817 | 89,617 |
| 10KB random | 7,331 | 1,500 | 510 | 1,941 |
| 1MB random | 21 | 8 | 24 | 22 |
Decompression (ops/sec, higher is better)
| Size | comprs | pako | fflate | node:zlib |
|---|---|---|---|---|
| 150B patterned | 124,501 | 21,119 | 8,938 | 34,967 |
| 10KB patterned | 23,416 | 5,808 | 3,061 | 9,753 |
| 1MB patterned | 306 | 65 | 70 | 116 |
| 150B random | 35,178 | 11,449 | 204,088 | 91,795 |
| 10KB random | 7,986 | 13,079 | 62,782 | 114,130 |
| 1MB random | 610 | 350 | 238 | 176 |
Cross-algorithm comparison (comprs only)
Compression (ops/sec, higher is better)
| Size | zstd | gzip | brotli | lz4 |
|---|---|---|---|---|
| 150B patterned | 515,284 | 78,073 | 24,300 | 274,935 |
| 10KB patterned | 161,706 | 31,941 | 15,146 | 165,821 |
| 1MB patterned | 5,607 | 2,252 | 642 | 4,153 |
| 150B random | 643,393 | 69,616 | 23,634 | 97,111 |
| 10KB random | 129,576 | 31,525 | 13,411 | 74,162 |
| 1MB random | 4,320 | 2,262 | 478 | 5,419 |
| JSON 84KB | 8,914 | 1,828 | 758 | 4,531 |
| text 45KB | 52,262 | 31,111 | 8,022 | 22,953 |
Decompression (ops/sec, higher is better)
| Size | zstd | gzip | brotli | lz4 |
|---|---|---|---|---|
| 150B patterned | 510,595 | 433,472 | 132,960 | 291,921 |
| 10KB patterned | 224,034 | 135,437 | 24,028 | 84,465 |
| 1MB patterned | 3,206 | 2,561 | 303 | 1,806 |
| 150B random | 461,787 | 441,290 | 30,870 | 371,571 |
| 10KB random | 245,513 | 135,314 | 23,516 | 89,173 |
| 1MB random | 3,498 | 2,309 | 656 | 1,792 |
| JSON 84KB | 17,345 | 9,737 | 4,644 | 20,356 |
| text 45KB | 82,783 | 29,115 | 3,415 | 41,811 |
- zstd is the fastest all-round: highest throughput for both compression and decompression across most data sizes
- lz4 excels at raw speed: competitive with zstd for compression
- gzip/deflate compression: comprs (Rust flate2) is significantly faster than pako and fflate on larger data (10KB+), competitive on small payloads
- gzip/deflate decompression: performance varies by data size; comprs leads on patterned data, while fflate can be faster on small random payloads due to lower call overhead
- Native vs WASM: these numbers are from the native (napi-rs) backend; WASM throughput is lower due to the execution overhead but still outperforms pure-JS libraries on large payloads
Note
Default decompression limit: All decompression functions cap output at 256 MB by default. Use *WithCapacity() variants, or the maxOutputSize argument of decompress() and decompressAsync(), for larger data:
const decompressed = zstdDecompressWithCapacity(data, 1024 * 1024 * 1024); // 1 GBThe capacity is a limit, not an allocation size: output buffers grow with the decompressed data, so a large capacity reserves no memory up front, and size fields in the input (the zstd frame content size, the gzip size trailer) are trusted only as far as the input can expand. Output over the limit throws an ... exceeded maximum size of <limit> bytes error.
Streaming decompression takes the limit as its maxOutputSize argument and enforces it while decoding: memory stays near maxOutputSize even when a single small chunk would expand to gigabytes.
Note
Numeric arguments: levels, qualities, capacity, maxOutputSize, maxDictSize, the crc32() initial value and the gzip header mtime must be integers in their documented ranges. Other numbers, such as NaN, Infinity, 1.5 or 2 ** 32, throw an error that names the argument and its range instead of being converted to a valid value (the async functions reject with it). capacity and maxOutputSize range from 0 to Number.MAX_SAFE_INTEGER, the same on every platform, and a limit of 0 accepts only data that decompresses to nothing; maxDictSize ranges from 0 to 16 MiB (16777216).
Note
Truncated and empty input: zstd, gzip, deflate and brotli decompression throw when the input ends before the compressed stream does, so an interrupted download or a partial file is never returned as a shorter result. Streams check this when their input ends; the decompression contexts check it in finish() (LZ4 contexts already in flush()). Empty input throws for every format, because no format has a valid zero-length encoding (node:zlib rejects it for gzip, deflate and brotli too): format-specific functions and streams report <format> stream is truncated: unexpected end of input, and auto-detection reports that it cannot detect the format.
Note
Stream context memory: the stream contexts (ZstdCompressContext, GzipDecompressContext and so on, which the streams use) keep their encoder or decoder state in native memory: up to a few hundred kilobytes for gzip, deflate and LZ4, several megabytes for zstd and brotli, and far more at high levels (about 90 MB for zstd level 19). They report it to V8, so that the garbage collector frees abandoned contexts in time. finish() releases it right away, and so does close() for a context that will not be finished; later calls throw <format> stream already closed. Contexts are disposable, so using ctx = new ZstdCompressContext() closes the context at the end of the scope. The Web streams and Node.js Transforms close their context when they end, fail, or are cancelled or destroyed. Closing a cancelled Web stream relies on the cancel() hook of TransformStream transformers, which Node.js supports; runtimes without it, such as Bun 1.3, leave the context to the garbage collector.
Note
One-shot zstd contexts: zstdCompress(), zstdDecompress(), zstdDecompressWithCapacity() and their *Async variants, and decompress() for zstd input, keep one compression and one decompression context per thread instead of creating one per call (the calling thread for the synchronous functions, the libuv pool threads for *Async), which makes small calls several times faster. A thread keeps a context only while it holds at most 8 MiB, so a large or high-level call does not leave its workspace behind; this memory is not reported to V8. The dictionary functions create a context per call.
Note
Small payloads on WASM: For data under ~1 KB, the WASM runtime overhead may exceed compression time. Consider batching small items or using the native Node.js backend where possible.
Note
Streams that buffer their input: LZ4 decompression streams (createLz4DecompressStream(), createLz4DecompressTransform(), and createDecompressStream() / createDecompressTransform() when the input is LZ4) and brotli dictionary compression streams (createBrotliCompressDictStream(), createBrotliCompressDictTransform()) hold their whole input in memory and produce their output only when the input ends. The other streams in @derodero24/comprs/streams and @derodero24/comprs/node work in bounded memory. Removing this buffering is tracked in #565.
HTTP compression middleware for Express, Fastify, and Hono.
npm install @derodero24/comprs @derodero24/comprs-middleware// Express
import { comprs } from '@derodero24/comprs-middleware/express';
app.use(comprs());
// Fastify
import { comprs } from '@derodero24/comprs-middleware/fastify';
app.register(comprs);
// Hono
import { comprs } from '@derodero24/comprs-middleware/hono';
app.use(comprs());See the middleware README for full documentation.
See CONTRIBUTING.md for development setup and guidelines.
See SECURITY.md for vulnerability reporting.