The Compression Streams API gives every modern browser a native gzip and deflate encoder, so you can shrink uploads, save files, and store data in IndexedDB without shipping pako or fflate. It's about ten lines of code. The trouble is that the short examples skip the parts that break in production: string conversion, streaming request bodies, server headers, and format support that's uneven across engines. This guide covers the API, then those failure modes.
What the API actually is
There are two classes, CompressionStream and DecompressionStream. Both are transform streams: bytes go in the writable side, compressed or decompressed bytes come out the readable side. They take ArrayBuffer or typed-array chunks and output Uint8Array chunks. The constructor takes a format string:
"gzip": deflate with the gzip header and CRC32 trailer. This is what servers understand asContent-Encoding: gzipand what.gzfiles use."deflate": deflate wrapped in the zlib format (RFC 1950), with an Adler-32 checksum."deflate-raw": the bare deflate bitstream with no header or checksum. ZIP files use this internally.
Those three formats have been Baseline in every major engine since May 2023, when Firefox 113 and Safari 16.4 caught up with Chrome. The Compression Standard has since added "brotli" and "zstd", but which engines ship them is still changing. Compatibility tables disagree, and MDN's browser-compat data has an open issue saying it's out of date. So don't trust a table. Detect support at runtime (see below).
Compressing a string, correctly
The most common bug is passing a string into the stream. It wants bytes. Encode first and decode last:
async function gzipString(text) {
const stream = new Blob([text]).stream()
.pipeThrough(new CompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function gunzipString(bytes) {
const stream = new Blob([bytes]).stream()
.pipeThrough(new DecompressionStream("gzip"));
return await new Response(stream).text();
}
Two tricks are doing the work here. new Blob([text]) UTF-8 encodes the string and gives you a readable stream through .stream(). new Response(stream) drains any readable stream into an arrayBuffer(), text() or blob(), so you don't have to write a reader loop. Both are standard and work in workers too.
If you write to the stream by hand instead (stream.writable.getWriter()), you must close() the writer. Until you do, the encoder never flushes its final block, and for gzip that block holds the trailer. If you forget, the output looks fine but is truncated, and the decoder later throws TypeError. Also, don't await writer.write() before you start reading on large inputs. Backpressure can deadlock you: the write waits for a reader that hasn't started yet. Start reading first, or use pipeThrough as shown above.
Feature detection that actually works
Checking "CompressionStream" in self only tells you the class exists. To find out whether a format is supported, try to construct it. Unsupported formats throw a TypeError right away:
function supports(format) {
try { new CompressionStream(format); return true; }
catch { return false; }
}
const best = ["zstd", "brotli", "gzip"].find(supports);
Remember that the server or the reader needs to support the same format. A browser that can encode zstd doesn't help if your API only decodes gzip. Negotiate the format, or just stick with gzip, which every server stack handles.
Compressed uploads: two routes
Buffered (works everywhere). Compress to bytes with the helper above, then send them:
await fetch("/api/events", {
method: "POST",
headers: { "Content-Type": "application/json", "Content-Encoding": "gzip" },
body: await gzipString(JSON.stringify(batch)),
});
Streaming (Chromium only). You can pass the compressed ReadableStream directly as the body, but only with duplex: "half", and only in Chromium-based browsers over HTTP/2 or later. Firefox and Safari don't support streaming request bodies, so code that works in Chrome fails elsewhere. Use it for large file uploads behind a check, with the buffered route as the fallback.
The server side is where most "it doesn't work" reports come from. Many frameworks and proxies don't decompress request bodies by default, even though they compress responses. Express's express.json() handles Content-Encoding: gzip through body-parser's inflate option. Nginx won't inflate request bodies without extra modules. Many serverless gateways pass the bytes through untouched. Test the endpoint with curl --data-binary @file.gz -H "Content-Encoding: gzip" before blaming the browser. And put a cap on decompressed size on the server. Otherwise one small gzip bomb can expand to gigabytes.
When it's worth it (and when it isn't)
- Good fits: analytics and telemetry batches, game replay or save files, large JSON exports, IndexedDB caches of text-heavy data, and logs. Repetitive JSON often compresses 5–10×.
- Poor fits: images, video, audio, and anything already compressed. Gzip makes these slightly bigger and spends CPU doing it. Payloads under about 1 KB rarely gain enough to cover the header.
- Downloads: you usually don't need this. Browsers already decompress
Content-Encodingresponses for you.DecompressionStreamis for files that are stored compressed, such as a.gzasset served asapplication/octet-stream, or data you saved compressed earlier. - No level control: the API doesn't let you choose a compression level or dictionary. If you need maximum ratio or a preset dictionary, keep a library like fflate.
For game saves and replays, I'd pair this with the input-log format in replay systems from an input log. Delta-encoded inputs plus gzip often bring an hour of play down to a few kilobytes. Run the compression in a worker (see moving game physics to a Web Worker) so a big save doesn't drop frames. If you store the result locally, read storage persistence and eviction first, because compressed blobs are still subject to the same quota rules.
Failure-mode checklist
- Passing a string instead of bytes. Wrap it in a
Blobor useTextEncoder. - Never closing the writer, which truncates output and makes later decoding throw
TypeError. - Awaiting writes before reading, which deadlocks on large inputs.
- Mismatched formats:
"deflate"isn't"deflate-raw", and neither is gzip. The decoder rejects the wrong framing. - Assuming brotli or zstd exist. Construct-and-catch to detect them.
- Streaming fetch bodies without
duplex: "half", or outside Chromium. - A server that ignores
Content-Encodingon requests, or has no decompressed-size limit. - Compressing media that's already compressed.
Support summary
- gzip / deflate / deflate-raw: Chrome/Edge 80+, Firefox 113+, Safari 16.4+, Node 18+. Baseline since May 2023.
- brotli / zstd: in the spec, but shipping varies by engine and version. Detect at runtime.
- Streaming request bodies (
duplex: "half"): Chromium only.