Compression Streams API: gzip in the browser without a library

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 as Content-Encoding: gzip and what .gz files 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.

string /File Blob.stream() CompressionStream("gzip") Response → bytes fetch body (duplex) bytes in → Uint8Array chunks out · close() flushes the trailer
Encode to bytes, pipe through the encoder, then drain to bytes or stream straight into fetch.

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-Encoding responses for you. DecompressionStream is for files that are stored compressed, such as a .gz asset served as application/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

  1. Passing a string instead of bytes. Wrap it in a Blob or use TextEncoder.
  2. Never closing the writer, which truncates output and makes later decoding throw TypeError.
  3. Awaiting writes before reading, which deadlocks on large inputs.
  4. Mismatched formats: "deflate" isn't "deflate-raw", and neither is gzip. The decoder rejects the wrong framing.
  5. Assuming brotli or zstd exist. Construct-and-catch to detect them.
  6. Streaming fetch bodies without duplex: "half", or outside Chromium.
  7. A server that ignores Content-Encoding on requests, or has no decompressed-size limit.
  8. 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.

Related: WebSocket reconnect done right · WebRTC DataChannel tuning · Portfolio

How do I gzip a string in JavaScript in the browser?

Wrap the string in a Blob, call .stream(), pipe it through new CompressionStream('gzip'), and drain the result with new Response(stream).arrayBuffer(). No library is needed.

Which formats does CompressionStream support?

gzip, deflate and deflate-raw work in all major browsers since May 2023. The spec also lists brotli and zstd, but engine support varies, so detect them by constructing the stream inside try/catch.

Can I upload a compressed stream with fetch?

Yes in Chromium, by passing the ReadableStream as body with duplex: 'half' over HTTP/2. Firefox and Safari do not support streaming request bodies, so compress to bytes first as a fallback.

Why does DecompressionStream throw a TypeError?

Usually the data was truncated because the writer was never closed, or the format does not match, for example deflate data decoded as gzip or deflate-raw.