Compression: Brotli & Zstandard for Every Text Response
This topic is part of Network & Server Response Optimization. Text-based resources — HTML, CSS, JavaScript, JSON, SVG, XML, web app manifests — are highly compressible. A typical JavaScript bundle shrinks to 25–30% of its size with gzip and further with modern algorithms. Compression is one of the oldest and most effective web performance techniques, and yet audits routinely find uncompressed API responses, gzip-only CDNs, static assets compressed on every request at low levels, and fonts or images being pointlessly recompressed.
Browsers advertise the encodings they accept in the Accept-Encoding request header (gzip, deflate, br, zstd in current Chromium-based browsers), and servers choose one, indicating it with Content-Encoding. Gzip is universal. Brotli (br), supported by all modern browsers over HTTPS, typically compresses text 15–25% better than gzip at comparable settings, with a built-in dictionary of common web strings that helps small files. Zstandard (zstd), supported in Chromium-based browsers and Firefox, compresses much faster than Brotli at similar ratios for mid-range levels, which suits dynamic responses. Compression dictionary transport adds shared dictionaries, so updated resources can be sent as small differences from a previous version.
The right setup uses each where it fits best: maximum-level Brotli for static assets, compressed once at build time; moderate Brotli or zstd for dynamic responses compressed on the fly; gzip as a fallback; and shared dictionaries for frequently updated bundles.
The Metric Degradation This Topic Addresses
Uncompressed or poorly compressed text increases transfer time for HTML (delaying FCP and LCP), render-blocking CSS (delaying first paint), and JavaScript (delaying interactivity and increasing INP during startup). The effect is largest on slow connections, where every 100KB matters. Lighthouse's "Enable text compression" audit flags uncompressed text responses; the Network panel shows transfer size versus resource size and the Content-Encoding of each response.
Compression also has a server-side cost: on-the-fly compression uses CPU per request, and maximum Brotli levels are too slow for dynamic responses — compressing a 200KB HTML response at level 11 can take tens of milliseconds, adding directly to TTFB. Choosing levels correctly avoids trading transfer time for server time.
Prerequisites
- A list of resource types served, with sizes and whether they are static (build artefacts) or dynamic (rendered per request).
- Access to server and CDN configuration.
- The ability to add a build step for precompression.
1. Environment Setup: Audit Current Encoding
# Check which encoding each key resource gets.
for u in / /css/app.css /js/app.js /api/products?limit=20; do
printf '%-28s ' "$u"
curl -s -o /dev/null -w '%{size_download}B ' -H 'Accept-Encoding: br, gzip, zstd' -D - "https://example.com$u" | grep -i '^content-encoding' | tr -d '\r' || echo 'none'
done
# trade-off: CDNs may vary encoding by client or cache state; test from several
# regions and with browser-like headers.
2. Capture a Baseline
Record transfer sizes for HTML, CSS, JS and API responses on key templates, plus server CPU usage and TTFB for dynamic responses.
3. Isolate Static vs Dynamic
Static assets (hashed CSS, JS, SVG, fonts' CSS) can be compressed once at build time at the highest settings. Dynamic responses (SSR HTML, API JSON) must be compressed per request at moderate levels.
4. Apply: Precompress Static, Tune Dynamic
Generate .br (and .gz, optionally .zst) files during the build and configure the server or CDN to serve them. Enable on-the-fly Brotli at level 4–6 or zstd at level 3–6 for dynamic content. Exclude already-compressed formats (images, video, WOFF2, archives).
// Build step: precompress text assets with Brotli 11 and gzip 9.
import { brotliCompressSync, gzipSync, constants } from 'node:zlib';
import { readFileSync, writeFileSync } from 'node:fs';
import { globSync } from 'glob';
for (const f of globSync('dist/**/*.{js,css,html,svg,json,xml,txt}')) {
const buf = readFileSync(f);
if (buf.length < 1024) continue; // tiny files: not worth it
writeFileSync(`${f}.br`, brotliCompressSync(buf, { params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: buf.length } }));
writeFileSync(`${f}.gz`, gzipSync(buf, { level: 9 }));
}
// trade-off: level 11 takes seconds for large bundles; run it once per build on
// hashed outputs, never per request.
Deconstructing Compression Levels and Windows
Compression algorithms trade CPU time for size. Higher levels search harder for repeated patterns. For Brotli, levels 0–4 are fast, 5–9 are moderate, and 10–11 are very slow but produce the smallest output. Zstandard's levels run from 1 (very fast) to 19 (slow), with 20–22 "ultra" levels. Gzip levels 1–9 vary less in output size.
Window size matters too: it determines how far back the compressor can look for repeated sequences. Larger windows help large files but require more memory in the decoder; browsers cap acceptable window sizes for safety. For zstd on the web, the window is limited (8MB), which is fine for web resources.
For small responses — under about 1KB — compression overhead can outweigh savings; many servers skip compression below a minimum size. Brotli's built-in dictionary helps small text files more than gzip, which is one reason it does well on short HTML and JSON responses.
Advanced Diagnostics and Edge Cases
Double compression. Proxies compressing already-compressed responses, or applications compressing and then a CDN compressing again, waste CPU and can corrupt responses if headers are wrong.
Missing Vary: Accept-Encoding. Without it, caches may serve a Brotli response to a client that cannot decode it.
Streaming and compression. Compression middleware can buffer output, breaking streaming SSR. Configure it to flush with each chunk.
BREACH and compression of secrets. Compressing responses that mix secrets (CSRF tokens) with user-controlled input can enable side-channel attacks; use per-request token masking.
CDN normalisation. Some CDNs normalise Accept-Encoding to a few buckets and may serve gzip when Brotli is possible unless configured.
Validation and Budgeting
In the Network panel, check that Content-Encoding is br (or zstd) for text resources in supporting browsers and gzip in others. Compare transfer sizes with your baseline. For dynamic responses, confirm TTFB did not increase. Budget compressed sizes for key bundles in CI, using Brotli sizes since that is what most users receive.
| Resource type | Recommended encoding | Level |
|---|---|---|
| Static JS, CSS, SVG (hashed) | Precompressed Brotli + gzip | br 11, gz 9 |
| SSR HTML | On-the-fly Brotli or zstd | br 4-6 / zstd 3-6 |
| API JSON | On-the-fly Brotli or zstd | br 4-5 / zstd 3 |
| Images, video, WOFF2 | None (already compressed) | - |
Worked Example: A SaaS Application
A SaaS dashboard served 1.9MB of JavaScript (uncompressed) through a CDN configured for gzip only, compressing on the fly at level 6; its API returned uncompressed JSON averaging 180KB per page. The team precompressed all hashed assets with Brotli 11 during the build, enabled Brotli level 5 for HTML and JSON at the CDN, and ensured Vary: Accept-Encoding was set. JavaScript transfer dropped from 560KB (gzip) to 455KB (Brotli 11); API JSON from 180KB to 24KB. On a 4G profile, dashboard load time improved by about 900ms and LCP p75 by 400ms. Origin CPU dropped too, because static assets were no longer compressed per request.
Choosing Between Brotli and Zstandard for Dynamic Content
For dynamic responses, the choice is about compression speed at a given ratio. Zstandard at levels 3–6 compresses several times faster than Brotli at comparable ratios, which matters on servers handling many requests or large HTML responses. Brotli at levels 4–5 is also fast and has universal modern browser support. A pragmatic approach: serve zstd to browsers that advertise it, Brotli to the rest of modern browsers, and gzip as the final fallback — if your server or CDN supports all three. If it supports only Brotli and gzip, Brotli at a moderate level is an excellent default.
Compression and Caching
Compressed variants must be cached separately per encoding. CDNs typically handle this automatically by keying on a normalised Accept-Encoding value; when they do not, Vary: Accept-Encoding instructs caches to store separate variants. A related consideration is whether the CDN or the origin compresses. Precompressed assets at the origin give the best ratio; CDN compression is convenient for dynamic content but may use lower levels. Check the CDN's configuration for which content types it compresses and at what level, and whether it passes through origin-compressed responses unchanged.
Shared Dictionaries for Repeat Visits
Compression normally starts from scratch for every response. Compression dictionary transport lets the browser keep a previous response (for example, last week's JavaScript bundle) as a dictionary, and the server sends only what changed in the new version. For sites that deploy frequently, this can reduce repeat downloads of updated bundles by 80–95%. It requires server support for dictionary-based Brotli or zstd and appropriate headers, and currently works in Chromium-based browsers. It is the most significant advance in HTTP compression in years for sites with large, frequently updated JavaScript.
What Compression Cannot Fix
Compression reduces transfer size, not the work the browser does afterwards. A 1MB JavaScript bundle compressed to 250KB still has to be decompressed, parsed and executed as 1MB of code, and on a mid-range phone that execution cost dominates. Likewise, a 300KB HTML document compressed to 40KB still produces a large DOM. Treat compression as the last step after reducing what you ship: code splitting, removing unused CSS and JavaScript, trimming markup, and avoiding oversized JSON payloads. A useful habit is to track both the compressed and uncompressed size of key bundles in CI; the compressed size predicts download time, while the uncompressed size predicts parse and execution cost.
Compression for APIs and Client-Rendered Data
Single-page applications often move most of their bytes from HTML to JSON. API responses are frequently left uncompressed because they pass through application servers or API gateways with different defaults from the web server. JSON is highly repetitive (repeated keys, similar values) and compresses exceptionally well — 85–95% reductions are common for list responses. Make sure API gateways and backend-for-frontend services compress responses with Brotli or zstd, and that the client sends Accept-Encoding (browsers do this automatically for fetch). For very large, repeated payloads, consider whether the response shape itself can shrink: pagination, field selection and avoiding deeply nested duplicate objects often save more than any algorithm.
Monitoring Compression in Production
Compression silently breaks during infrastructure changes: a new CDN rule, a proxy upgrade, a framework middleware change. Add a synthetic check that requests key resources with a browser-like Accept-Encoding header and asserts the expected Content-Encoding and an upper bound on transferred bytes. In RUM, Resource Timing exposes encodedBodySize and decodedBodySize for same-origin resources (and cross-origin ones with Timing-Allow-Origin); a ratio close to 1 for a text resource means it arrived uncompressed. Alerting on that ratio for scripts and stylesheets catches regressions within hours.
A Rollout Plan
- Audit encodings for every resource type on key templates.
- Add precompression (Brotli 11, gzip 9) to the build for static text assets.
- Configure the server or CDN to serve precompressed files and set
Vary: Accept-Encoding. - Enable on-the-fly Brotli (4–6) or zstd (3–6) for HTML and JSON.
- Exclude already-compressed types from compression.
- Consider dictionary transport for large, frequently updated bundles.
- Add CI checks for encoding headers and compressed bundle budgets.
Common Pitfalls
- gzip-only CDN configuration. Leaves 15–25% savings unused.
- Brotli 11 on the fly. Adds tens of milliseconds to TTFB per response.
- Uncompressed API responses. JSON compresses extremely well; often forgotten.
- Compressing images and fonts. Wasted CPU with no size benefit.
- Compression middleware that breaks streaming. Buffering hides the benefits of streaming SSR.
FAQ
Is Brotli supported everywhere?
All modern browsers support Brotli over HTTPS. Keep gzip as a fallback for older clients and some bots.
Which Brotli level should I use?
Level 11 for static assets compressed at build time; level 4–6 for dynamic responses compressed per request.
Should I enable Zstandard?
If your server or CDN supports it, zstd is a good choice for dynamic responses to supporting browsers because it compresses quickly. Keep Brotli and gzip for others.
Does compression help images?
No. Formats like JPEG, PNG, WebP, AVIF and video are already compressed. SVG is text and does benefit.
How do I know if my CDN uses Brotli?
Check the Content-Encoding response header for text resources in a modern browser. If it says gzip, Brotli is not enabled or not passed through.
Does compression increase TTFB?
On-the-fly compression adds a little CPU time. At moderate levels it is a few milliseconds, more than repaid by faster transfer. High levels on the fly can add noticeably.
Should HTML be precompressed?
Static HTML from a static site generator can be precompressed like other assets. Server-rendered HTML must be compressed per request.
How much does compression improve LCP?
It depends on how much of the LCP path is text transfer. For pages where render-blocking CSS and large HTML dominate, moving from gzip to Brotli typically improves LCP by tens to a couple of hundred milliseconds on mobile; going from no compression to Brotli can save far more.
Should compression happen at the origin or at the CDN?
Static assets are best precompressed at the origin at maximum levels. Dynamic responses can be compressed at either; compressing at the CDN offloads CPU from the origin, while compressing at the origin lets you choose algorithms and levels precisely.
Is gzip still needed?
Yes, as a fallback for clients that do not advertise Brotli or zstd, including some tools and crawlers.
Guides in This Topic
- Enabling Brotli on nginx and CDNs — server and CDN configuration.
- Zstandard Content-Encoding for the web — when and how to serve zstd.
- Precompressing static assets at build time — maximum compression without runtime cost.
- Compression dictionary transport — sending only what changed.
Related
- Network & server response optimization — the full network picture.
- Streaming HTML through CDNs and proxies — compression that does not buffer.
- JavaScript bundle optimization — shipping fewer bytes before compressing them.