CompressionStream is the browser’s built-in gzip. Pass it 'gzip', pipe any ReadableStream through it, and the bytes that come out are a standard gzip file that gunzip, zlib or any server can read. DecompressionStream reverses it. Both have been Baseline across browsers since May 2023 and global in Node since v18, so you can delete pako from most bundles.
The MDN reference and the WHATWG Compression spec are both short. Neither shows the two helper functions everyone ends up writing, or the server code that has to accept a gzipped upload safely.
How do you gzip a string with CompressionStream?
The API only deals in streams, so the trick is getting a string into one and the bytes back out. Blob and Response do both jobs without any manual chunk handling.
export async function gzip(text: string): Promise<ArrayBuffer> {
const stream = new Blob([text])
.stream()
.pipeThrough(new CompressionStream('gzip'));
return new Response(stream).arrayBuffer();
}
export async function gunzip(data: ArrayBuffer): Promise<string> {
const stream = new Blob([data])
.stream()
.pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).text();
}
new Blob([text]) encodes the string as UTF-8 and .stream() hands you a ReadableStream of bytes. pipeThrough returns the compressed readable side. Wrapping that in a Response is the shortest way to drain a stream into an ArrayBuffer or a string, and it is the pattern MDN’s own example uses.
The output starts with the gzip magic bytes 1f 8b, so it is a real .gz. The code above type-checks under TypeScript 5.8 with lib: ["DOM"] and runs unchanged in Node 24.
One thing to know before you reach for it on tiny payloads: a gzip member carries a 10-byte header and an 8-byte trailer (RFC 1952). For a 200-byte JSON blob that overhead, plus the async round trip, buys you almost nothing. Compression pays on the kilobytes-and-up payloads that repeat themselves, which is most JSON.
Which formats does CompressionStream support?
The spec’s CompressionFormat enum now has four values: "gzip", "deflate" (zlib-wrapped, with an Adler-32 checksum), "deflate-raw" (no header, no checksum) and "brotli". Brotli is the newcomer, and support is uneven. As of October 2026, caniuse shows it in Safari from 18.4 and Firefox from 147, but not in Chrome or Edge. Node accepts it from v24.7.0 and v22.20.0, and deflate-raw from v21.2.0 and v20.12.0 (Node web streams docs).
An unknown format throws a TypeError from the constructor, which makes feature detection cheap:
export function supportsFormat(format: string): boolean {
try {
new CompressionStream(format as CompressionFormat);
return true;
} catch {
return false;
}
}
export const uploadFormat = supportsFormat('brotli') ? 'br' : 'gzip';
The cast is there because TypeScript’s DOM lib may not list "brotli" yet, depending on your version. MDN’s page also mentions "zstd", but it is not in the spec’s enum and Node 24 rejects it, so treat it as not shipped.
If you are wondering about .zip files: CompressionStream does not make archives. A zip holds raw DEFLATE data inside its own container format, so deflate-raw is the piece you would use if you were writing a zip encoder yourself. For anything people will double-click, gzip is the format they can open.
When is browser-side compression worth doing?
Not for responses. Your CDN or web server already negotiates Content-Encoding with the browser, and decoding a gzipped download by hand in JavaScript is slower and strictly worse. Requests are different: the browser sends every fetch body exactly as you gave it.
So uploads are where this pays first. Analytics batches, error reports with breadcrumbs, autosaved form state, a big JSON document from an editor: all repetitive text, all sent uncompressed by fetch. Local storage comes next, since IndexedDB and the Cache API both have quotas and a compressed blob of offline data fits more under them. Then there are files the user hands you. A .gz log export can be decompressed and processed as it streams, without loading the whole thing into memory:
export async function countLines(file: Blob): Promise<number> {
const reader = file
.stream()
.pipeThrough(new DecompressionStream('gzip'))
.pipeThrough(new TextDecoderStream())
.getReader();
let lines = 0;
while (true) {
const { done, value } = await reader.read();
if (done) return lines;
for (const ch of value) if (ch === '\n') lines++;
}
}
A File from an <input type="file"> is a Blob, so it drops straight in. The reader loop is deliberate. Async iteration over a ReadableStream reads better, but it reached browsers years after DecompressionStream did and TypeScript only types it with the DOM.AsyncIterable lib. The explicit read() loop works wherever DecompressionStream does.
Compressed bytes also make a decent URL fragment for shareable app state once you encode them. Our post on base64 without btoa covers the encoding half, with the base64url alphabet that survives a URL. Pair it with deflate-raw to skip the gzip header you do not need there.
How do you upload gzipped JSON with fetch?
Compress the body, then tell the server what you did with Content-Encoding. That header is not on the forbidden request header list, so fetch lets you set it.
import { gzip } from './gzip';
type AnalyticsEvent = { name: string; path: string; ts: number };
export async function sendEvents(events: AnalyticsEvent[]): Promise<void> {
const body = await gzip(JSON.stringify(events));
const res = await fetch('/api/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Encoding': 'gzip',
},
body,
});
if (!res.ok) throw new Error(`upload failed: ${res.status}`);
}
The body is a finished ArrayBuffer, not a stream, and that is on purpose. Streaming a request body needs duplex: 'half', and Chrome’s streaming requests guide lists the catches: HTTP/1.x connections are rejected, every request is preflighted, and Safari does not support it with fetch at all. Buffering the compressed bytes first works everywhere.
Cross-origin, remember that Content-Encoding is not a CORS-safelisted header. The server’s preflight response has to allow it in Access-Control-Allow-Headers, or the browser will never send the request.
What does the server need to do?
Check what your framework does first. Express’s body-parser inflates gzip, br and deflate bodies by default through its inflate option. Bare node:http hands you the compressed bytes, so the endpoint has to check the header, gunzip, and cap the size of what comes out. That last part matters. DEFLATE can shrink a run of zeros roughly a thousandfold, so a 100 KB upload that passes your request-size limit can inflate to 100 MB in memory.
import { createServer } from 'node:http';
import { pipeline } from 'node:stream/promises';
import { Transform } from 'node:stream';
import { createGunzip } from 'node:zlib';
const MAX_BYTES = 1_000_000; // cap on the *decompressed* size
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/api/events') {
res.writeHead(404).end();
return;
}
const encoding = req.headers['content-encoding'];
if (encoding !== undefined && encoding !== 'gzip') {
res.writeHead(415).end(`Unsupported Content-Encoding: ${encoding}`);
return;
}
const chunks: Buffer[] = [];
let total = 0;
const collect = new Transform({
transform(chunk: Buffer, _enc, done) {
total += chunk.length;
if (total > MAX_BYTES) {
done(new Error('payload too large'));
return;
}
chunks.push(chunk);
done();
},
});
try {
if (encoding === 'gzip') {
await pipeline(req, createGunzip(), collect);
} else {
await pipeline(req, collect);
}
const events = JSON.parse(Buffer.concat(chunks).toString('utf8'));
res.writeHead(204).end();
console.log(`received ${events.length} events`);
} catch (err) {
res.writeHead(400).end((err as Error).message);
}
}).listen(3000);
The Transform counts decompressed bytes and aborts the pipeline the moment the cap is crossed, so a bomb never gets fully inflated. A 415 for encodings you have not wired up means a client that sends brotli finds out straight away rather than getting a confusing JSON parse error. If you do accept br from the detection snippet above, add a createBrotliDecompress() branch alongside the gzip one.
What happens with corrupt or truncated data?
DecompressionStream errors the stream with a TypeError. The spec is strict about it: a bad checksum, a truncated stream and trailing bytes after the end of the gzip member are all errors. With the helpers above, that surfaces as a rejected promise from gunzip, so wrap calls on anything you did not compress yourself in try/catch. Log the file name and byte length with it, because a bare TypeError tells you very little about which upload went wrong.
Should you replace pako with CompressionStream?
For gzip and deflate, yes. The native API covers both directions, runs in workers, needs no bundle bytes, and has been in every major browser for over three years. Keep a library only if you need synchronous compression, compression-level control (the native API exposes none), or brotli in Chrome today. Do not bother compressing payloads of a few hundred bytes. And whatever you send compressed, cap the decompressed size on the server before you ship it.
If you want the client and the endpoint built together, with the size cap and the 415 path in from the start, that is part of our web application development work.