Gzip in the browser without a library
For years, gzip compression in the browser meant loading pako or a WASM port of zlib, often several hundred kilobytes just for the library. The Compression Streams API makes that unnecessary: CompressionStream and DecompressionStream are available natively in the browser.
Table of Contents
- 1. Why native compression was missing from the browser for so long
- 2. Basics: CompressionStream as a TransformStream
- 3. DecompressionStream: making data readable again
- 4. Real-world case: compressing large uploads
- 5. Real-world case: saving IndexedDB storage space
- 6. Comparison to pako and JSZip
- 7. Browser support and feature detection
- 8. Performance notes for production use
- 9. Conclusion: native compression as a standard tool
- 10. Summary
- 11. FAQ
1. Why native compression was missing from the browser for so long
Servers have compressed HTTP responses with gzip or brotli as a matter of course for decades, yet the browser itself was long unable to compress or decompress arbitrary data. Anyone wanting to generate or read gzip data client-side, for example to shrink a large JSON object before uploading it, inevitably had to load a JavaScript or WebAssembly library like pako that reimplements zlib in the browser.
The Compression Streams API closes that gap directly at the platform level. It provides two classes, CompressionStream and DecompressionStream, which work as a TransformStream in the sense of the Streams API and perform gzip and deflate compression natively, that is, using the browser's highly optimized C++ implementation, with no extra JavaScript bundle at all.
2. Basics: CompressionStream as a TransformStream
CompressionStream is instantiated with a format string, currently 'gzip', 'deflate' or 'deflate-raw', and then behaves like any other TransformStream: it has a writable side that uncompressed bytes get written into, and a readable side that compressed bytes can be read from. This streaming nature is the decisive difference from classic libraries, which usually have to hold the entire input in memory.
The most common way to compress a blob or a ReadableStream is pipeThrough(), which pipes the source directly through the CompressionStream. The result is again a ReadableStream, which can, for example, be turned directly into a new blob or passed to fetch() as the request body, without the complete data ever having to sit fully in memory.
// Compress text and get back a Blob
async function compressText(text) {
const stream = new Blob([text]).stream();
const compressedStream = stream.pipeThrough(new CompressionStream('gzip'));
return new Response(compressedStream).blob();
}
const compressed = await compressText('A very long JSON string...');
console.log('Compressed size:', compressed.size, 'bytes');
3. DecompressionStream: making data readable again
DecompressionStream works as a mirror image: it accepts gzip- or deflate-compressed bytes on the writable side and delivers the original, uncompressed bytes on the readable side. The format specified at instantiation must exactly match the format originally used for compression, otherwise the stream throws an error while reading.
A common use case is decompressing a server response that is deliberately already delivered compressed, for example because the server uses its own compression format on top of the regular HTTP content-encoding negotiation, or reading back data you previously compressed yourself and stored in IndexedDB.
// Turn a compressed Blob back into text
async function decompressToText(compressedBlob) {
const stream = compressedBlob.stream()
.pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).text();
}
const original = await decompressToText(compressed);
console.log(original);
4. Real-world case: compressing large uploads
One concrete use case is compressing large text data, such as CSV exports or sizable JSON payloads, before uploading them to a server that accepts gzip-compressed request bodies. Instead of transferring megabytes of raw, uncompressed data, gzip typically shrinks text content to a tenth or fifth of its original size, which noticeably saves time, especially on mobile connections.
It's important to set the matching Content-Encoding header so the server knows it needs to decompress the body before processing it. Most modern backend frameworks already support incoming gzip-compressed bodies natively, so no extra server-side logic is usually needed.
// Upload a large JSON export compressed
async function uploadCompressed(data) {
const json = JSON.stringify(data);
const compressedStream = new Blob([json]).stream()
.pipeThrough(new CompressionStream('gzip'));
const compressedBlob = await new Response(compressedStream).blob();
return fetch('/api/export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Encoding': 'gzip',
},
body: compressedBlob,
});
}
5. Real-world case: saving IndexedDB storage space
IndexedDB is subject to browser-specific storage quotas that vary depending on available disk space and usage patterns. In offline-first applications that keep large amounts of text or JSON data locally, for example a full offline copy of a product catalog, it pays off to compress entries before storing them, significantly increasing the effectively usable capacity.
Since CompressionStream works with bytes rather than strings, text data must first be turned into a Uint8Array via TextEncoder. When reading back from IndexedDB, the process reverses: DecompressionStream delivers bytes again, which get turned back into a string via TextDecoder before being parsed as JSON.
// Store compressed in IndexedDB and read it back
async function compressForStorage(obj) {
const bytes = new TextEncoder().encode(JSON.stringify(obj));
const stream = new Blob([bytes]).stream()
.pipeThrough(new CompressionStream('gzip'));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function decompressFromStorage(compressedBytes) {
const stream = new Blob([compressedBytes]).stream()
.pipeThrough(new DecompressionStream('gzip'));
const text = await new Response(stream).text();
return JSON.parse(text);
}
// Usage with an IndexedDB object store:
const compressed = await compressForStorage(largeProductCatalog);
await productStore.put({ id: 'catalog', data: compressed });
6. Comparison to pako and JSZip
pako is a pure JavaScript port of zlib and runs entirely in the main thread or in a self-managed worker, while the native Compression Streams API is based on the browser's internal C++ implementation and is therefore usually noticeably faster, especially for larger amounts of data, and adds no extra bundle weight whatsoever.
JSZip solves a different problem, namely creating and reading ZIP archives containing multiple files and a directory structure, and remains the right choice for that, since the Compression Streams API only offers raw gzip/deflate compression, no archive-format logic. For the simple case of compressing individual data streams, though, the native API is clearly the leaner and faster solution.
Another practical difference shows up in testing and debugging: since pako is pure JavaScript, intermediate state can be inspected with the debugger when needed, while the native implementation behaves like a black box where only the streams' input and output are observable. For the vast majority of use cases this is no drawback, since the compression logic itself almost never needs adjusting, but for very specific debugging requirements it can tip the scale toward pako.
7. Browser support and feature detection
The Compression Streams API is supported by all current versions of Chrome, Edge, Firefox and Safari, and support is now considerably broader than, say, the Navigation API or Background Sync. For older browser versions, a simple feature detection before using the API is still advisable.
If CompressionStream isn't available, pako can still be loaded as a fallback, but only dynamically via import(), so users of modern browsers don't have to carry the extra bundle weight. This pattern, modern API first, library as a lazy fallback, considerably reduces the average bundle size for the majority of users.
// Feature detection with a lazy fallback
async function getCompressor() {
if (typeof CompressionStream !== 'undefined') {
return { native: true };
}
const pako = await import('pako');
return { native: false, pako };
}
8. Performance notes for production use
Since CompressionStream and DecompressionStream are fully based on streams, it's worth actually processing them as a stream for very large amounts of data instead of, as in the simple examples above, collecting the entire content back in memory via new Response(stream).blob(). Piping directly into a destination stream with pipeTo(), for example the body of a fetch() request, avoids unnecessary intermediate buffering.
For very small amounts of data, such as short strings under a few hundred bytes, compression often isn't worth it, since the gzip header overhead eats up the saved bytes or even exceeds them. A simple rule of thumb is to only apply compression from roughly 1 to 2 KB of raw data onward; below that, it usually provides no practical benefit.
When repeatedly compressing similar data structures, for example many small JSON objects sharing the same schema, it also pays off to batch several objects together before compressing rather than compressing each one individually. Gzip benefits from recurring patterns within a single data stream, so a bundled batch compresses noticeably better overall than the same amount of data sent as many small individual calls, each carrying its own compression header.
9. Conclusion: native compression as a standard tool
The Compression Streams API makes external compression libraries largely unnecessary for the standard case, gzip or deflate compression of individual data streams. It's faster, adds no extra bundle weight, and fits seamlessly into the browser's existing Streams API landscape.
For more complex use cases such as ZIP archives with multiple files, specialized libraries like JSZip remain necessary, but for upload compression and space-saving IndexedDB storage, the native API is today the right first choice. The table below compares the key properties.
| Property | Compression Streams API | pako | JSZip |
|---|---|---|---|
| Bundle size | 0 KB, native in the browser | ~45 KB minified | ~95 KB minified |
| Format | Gzip, Deflate, Deflate-Raw | Gzip, Deflate, Zlib | ZIP archives |
| Streaming capable | Yes, real TransformStream | Partially, with extra code | No, usually fully in memory |
| Use case | Compressing individual data streams | Fallback for older browsers | Multiple files in one archive |
Mironsoft
Modern browser APIs, performance, and maintainable JavaScript
JavaScript that holds up in the real browser, not just in the tutorial?
We review existing frontend code for outdated patterns, unnecessary dependencies, and performance traps, then replace them with modern, native browser APIs that mean less bundle weight and less maintenance burden.
Code Review
Systematically finding outdated patterns, unnecessary dependencies, and memory leaks.
Performance Optimization
Improving bundle size, load time, and runtime performance with modern APIs.
Modernization
Deliberately introducing native browser APIs instead of heavy libraries.
10. Summary
Compression Streams API: The Key Facts at a Glance
CompressionStream
A TransformStream that natively compresses bytes as gzip or deflate
DecompressionStream
The counterpart, decompressing bytes back to their original state
Use cases
Upload compression and space-saving IndexedDB storage
Versus pako
No extra bundle weight, native C++ speed