LZ4 compression — ultra-fast algorithm, block and streaming compression/decompression.

Module lz4 | Source packages/front/fw/src/io/compress/lz4.js | Deps none | Worker-safe yes

Resolve

const lz4 = runtime.resolve('lz4');
// Returns: { compress, decompress, Lz4CompressStream, Lz4DecompressStream }

API

lz4.compress(src, len, opts?) → [size, data]

Compresses a Uint8Array block.

const [size, data] = lz4.compress(uint8Array, uint8Array.length);                      // mode 'speed'
const [rSize, rData] = lz4.compress(uint8Array, uint8Array.length, { mode: 'ratio' }); // smaller, slower
Option Values Default
opts.mode 'speed' (fast search) or 'ratio' (deeper search, smaller block) — see Modes 'speed'
Return Meaning
size > 0 Success — size = compressed size
size === 0 Incompressible data — return original
size === -1 Error — input exceeds ~1 GB

Throws a RangeError naming the value when opts.mode is neither 'speed' nor 'ratio'. Both modes produce standard LZ4 blocks: decompress reads either.

lz4.decompress(src) → [size, data]

Decompresses an LZ4 block.

const [origSize, original] = lz4.decompress(compressed);
Return Meaning
size > 0 Success — size = decompressed size
size === -1 Decompression error

new lz4.Lz4CompressStream(ondata?, opts?)

Streaming compression. Each chunk is processed as an independent LZ4 block. opts.mode is the same as for compress (default 'speed'); an unknown mode throws a RangeError at construction.

const chunks = [];
const stream = new lz4.Lz4CompressStream((chunk) => chunks.push(chunk));

stream.push(data1);
stream.push(data2);
stream.push(lastChunk, true);  // true = dernier chunk

new lz4.Lz4DecompressStream(ondata?)

Streaming decompression.

const output = [];
const stream = new lz4.Lz4DecompressStream((chunk) => output.push(chunk));

stream.push(compressedChunk);
stream.push(lastCompressedChunk, true);

Characteristics

Aspect Value
Maximum input size ~1 GB (0x3F000000 bytes)
Minimum match length 4 bytes
Compression modes 'speed' (default, 1 candidate per position) / 'ratio' (chain of 4) — same block format
Decompression buffer allocation One exact-size allocation — a validation pass sums the decoded size first
Streaming Each chunk = independent LZ4 block

Modes

Both modes run the same encoder and emit the same block format; they differ only in how hard the match search works.

'speed' (default) 'ratio'
Candidates per position 1 (newest bucket entry, no chains) up to 4 (hash chains)
Positions indexed inside an emitted match none the last 3
Miss run before the stride grows 32 64

Measured with the codec bench (tools/bench/codec-levels.js --lz4-mode, Bun 1.3.13, 256 KiB corpora, small = 200 × 2 KiB slices; encode time in ms, median of 7), next to the single-mode encoder this module replaced (pre-rewrite baseline):

Corpus previous encoder: B / ms 'speed': B / ms 'ratio': B / ms
text 84076 / 0.83 83621 / 0.85 77053 / 1.32
source 57987 / 0.51 57188 / 0.57 50538 / 0.71
json 90883 / 0.52 90742 / 0.63 75186 / 0.94
random 262144 / 0.09 262144 / 0.07 262144 / 0.09
repeat 1142 / 0.21 1135 / 0.21 1135 / 0.16
small 218866 / 12.9 217745 / 1.96 209947 / 2.58

'speed' is never larger than the previous encoder on these corpora and encodes at about its speed (from −85 % on many small inputs to +20 % on short-match text such as json); 'ratio' trades 1.5 to 1.8 times the encode time on large text for blocks 8 to 17 % smaller. Both decode with the same decompress, within about 10 % of each other (a 'speed' block carries more literals, a 'ratio' block more matches).

Full example

const lz4 = runtime.resolve('lz4');
const utf8 = runtime.resolve('utf8');

const data = utf8.toBytes('Hello World '.repeat(1000));

// Block
const [size, compressed] = lz4.compress(data, data.length);
console.log(`${data.length} → ${size} bytes`);

const [origSize, restored] = lz4.decompress(compressed);
console.log(utf8.fromBytes(restored));  // 'Hello World Hello World ...'

// Streaming compression
const parts = [];
const comp = new lz4.Lz4CompressStream((chunk) => parts.push(chunk));
comp.push(data.slice(0, 500));
comp.push(data.slice(500), true);

Worker Usage

const worker = fw.createWorker(
    function ({ libs, args }) {
        const lz4 = libs.lz4;
        const [size, compressed] = lz4.compress(args.data, args.data.length);
        self.postMessage({ size, compressed }, [compressed.buffer]);
    },
    { dependencies: ['lz4'], args: { data: new Uint8Array(rawData) } }
);

Notes

  • LZ4 favours speed (hundreds of MB/s) over ratio — for maximum ratio, use brotli (quality 11) or deflate (level 9).
  • Block format: each compress call produces an independent LZ4 block — no multi-block LZ4 frame header.
  • Lz4CompressStream / Lz4DecompressStream: each push produces one or more complete blocks (no inter-push buffering).

Design

Match search (hash, chain, skip). Every visited position hashes its 4-byte little-endian word multiplicatively (Math.imul by 2654435761, top hashBits bits, with hashBits between 8 and 16 sized to the input) into a bucket table. In 'ratio' mode each bucket heads a chain linked through a 64 KiB ring indexed by position, so a lookup walks older candidates newest first: at most 4 inside the 64 KiB window, keeping the longest and stopping early on a match of 32 bytes or one that reaches the end limit. In 'speed' mode only the newest entry of the bucket is tried, and no chain is kept at all (no ring is allocated or written). A cheap test comes first: the byte at the current best length must match before the whole 4-byte word is compared. The kept match is then widened backwards over the pending literals (both modes: without it, 'speed' would exceed the previous encoder's size on the text corpora). After a match, 'ratio' indexes its last 3 covered positions, 'speed' none. After a run of misses the stride grows by one every 64 ('ratio') or 32 ('speed') misses, so incompressible stretches are sampled instead of scanned. The two modes are one function with three parameters (probes, covered positions, skip shift); 'ratio' is byte-identical to the single-mode encoder it replaced, and the parameterisation costs it nothing measurable.

Match extension. Past the first 4 bytes, a candidate is compared byte by byte up to 16 bytes, because most matches end there. A candidate that gets that far is compared in 4-byte words, and a final byte loop finishes whatever the word loop leaves before the end limit. The length found is always the exact common prefix; lz4.test.js checks it for every run length from 7 to 96 and at the end-of-block cut.

Literal copies. Literals are copied byte by byte in both the encoder and the decoder. Literal runs between matches are short, and a set(subarray(...)) would allocate a view object for every sequence (a Buffer when the input is one), which costs more than the copy it saves.

What the chain costs. The 4-probe chain and the 3-position indexing are what buy 'ratio''s smaller blocks; 'speed' drops both and keeps only a fraction of the size gain (see Modes). Among the configurations measured for 'speed' (1 or 2 probes, 0 to 3 indexed positions, with and without backward widening, skip after 16, 32 or 64 misses, 14 to 16 hash bits), the one shipped is the fastest overall that stays at or below the previous encoder's size on every bench corpus.

Decoder. Two passes: the first validates every sequence and sums the output size (so the size limit is checked before any allocation), the second copies into one exact-size buffer. A non-overlapping match longer than 32 bytes uses copyWithin. Overlapping matches are copied byte by byte, because they re-read bytes they have just written.

Benchmark

tools/bench/codec-levels.js --lz4-mode {speed,ratio}, --reps 7 --warmup 3, baseline sha d47f8fc7a68809f519fe7d36ff2b0ba88bcacdbd (the single-mode encoder this module replaced). Host win32 / 13th Gen Intel(R) Core(TM) i7-13700KF / Bun 1.3.13. Generated 2026-09-24T21:14:48.449Z (speed) and 2026-09-24T21:14:48.950Z (ratio), both gitignored bench artifacts.

Gate reading (a later measurement ruling). The rule for lz4 is Δbytes ≤ 0 per corpus — the only gated figure; the mode's own encode time is recorded, not gated: 'speed''s delta is the accepted measurement floor (the fastest configuration found that still keeps bytes ≤ 0 on every corpus), and 'ratio''s delta is a recorded compression trade-off. Both decode with the unchanged decompress.

'speed' (default)

Corpus Old B New B Δbytes % Δenc % (reps 7) Δenc % (reps 21) Δdec % Bytes gate
text 84076 83621 -0.54 +3.1 +27.8 -28.9 PASS
source 57987 57188 -1.38 +22.1 +10.7 -12.1 PASS
json 90883 90742 -0.16 +16.9 +14.3 -25.9 PASS
random 262144 262144 0.00 -17.6 -22.8 -100.0 PASS
repeat 1142 1135 -0.61 -0.7 -16.3 -63.0 PASS
small 218866 217745 -0.51 -82.9 -83.5 -73.2 PASS

Bytes ≤ 0 on all 6 corpora. The reps-7/reps-21 spread on text/source/json (companion artifact 05-supp-lz4-speed-reps21-20260924T2114Z.json, gitignored) is the harness's own noise floor — the baseline side alone moved from 0.82 to 0.65 ms on text between the two runs — not a regression; it is accepted as the measured floor by the ruling.

'ratio'

Corpus Old B New B Δbytes % Δenc % (reps 7) Δenc % (reps 21) Δdec % Bytes gate
text 84076 77053 -8.35 +55.2 +54.3 -29.5 PASS
source 57987 50538 -12.85 +44.4 +56.6 +11.7 PASS
json 90883 75186 -17.27 +76.5 +83.0 -28.0 PASS
random 262144 262144 0.00 +8.8 +3.6 +0.0 PASS
repeat 1142 1135 -0.61 -27.6 -9.9 -64.7 PASS
small 218866 209947 -4.08 -79.3 -78.4 -56.3 PASS

Bytes ≤ 0 on all 6 corpora. Encode time (+44 to +83 % on text/source/json) is recorded as the ratio trade-off, not gated — this is the same encoder the module shipped before the speed/ratio split, byte-identical by hash (gitignored companion artifact, reps 21).

Totals: 12/12 PASS on the gated figure (bytes ≤ 0, both modes, all 6 corpora); 0 cells fail on bytes. No byte was traded for time in the default: 'speed' stays at or below the previous encoder's size on every corpus (see Modes for the configuration search).

Provenance

In-house implementation written from the LZ4 Block Format specification; the earlier node-lz4-derived implementation was replaced by a clean-room rewrite (2026-09).

See also