1. The Change That Made The Site Slower
A distributor I work with turned Brotli on across their Magento stack in October and their TTFB got worse. Not dramatically — p75 went from 340ms to 410ms — but consistently, and on the one metric they had spent the previous quarter improving.
The change was a single line. Someone had read that Brotli beats gzip, found the Nginx directive, and set brotli_comp_level 11 because eleven is the best number. Every dynamically-generated HTML response and every GraphQL payload was now being compressed at the highest setting the algorithm offers, in the request path, on servers that were already at 60% CPU during business hours.
They were saving about 9KB per page against gzip. They were spending 70 to 180 milliseconds of server CPU to do it, per request, on responses that were then travelling over connections fast enough that 9KB took 12 milliseconds.
Dropping to level 5 gave back the TTFB and kept most of the byte saving. Precompressing the static bundles at level 11 during the build gave back the rest of it and more. The whole fix took two hours and it was the second half — the build-time part — that mattered.
This is an article about that trade. Brotli is genuinely better than gzip, the improvement is real and worth having, and the way most people enable it costs them more than it saves. What follows is how the algorithm differs, where the levels actually sit, how to precompress properly, what does not compress at all, and how to measure whether any of it moved a number a customer can feel.
2. What Brotli Actually Does Differently
Gzip is DEFLATE, which is LZ77 plus Huffman coding, and it was standardised in 1996. It is remarkable that it is still competitive. Brotli, published by Google in 2013 and standardised as RFC 7932 in 2016, is the same family of ideas with three substantial changes.
A much larger window. LZ77 works by replacing a repeated sequence with a back-reference: "the next 40 bytes are the same as the 900 bytes ago". The window is how far back it can look. Gzip's is 32KB, fixed. Brotli's is configurable up to 16MB, and defaults to something in the low megabytes depending on the level.
For a 40KB stylesheet this is irrelevant — the whole file fits in gzip's window anyway. For a 1.2MB JavaScript bundle it is enormous, because the bundle repeats itself constantly. Webpack helpers, framework internals, the same option-object shape appearing in two hundred call sites. Gzip cannot see past 32KB and so it re-encodes those repetitions again and again. Brotli references them once.
This is why the advertised "15–25% better than gzip" figure is so variable in practice. On small files it is closer to 8%. On large bundles I have measured 27%.
Context modelling. Brotli picks its Huffman tables based on the preceding bytes, so it can use different statistics for the middle of a string literal than for a run of syntax characters. Gzip uses one set of statistics for the whole block. This is where most of the remaining gain on text comes from and it is also a large part of why Brotli is slower to encode.
The static dictionary. The specification embeds a fixed 122KB dictionary of common web strings — around 13,500 words and fragments — plus a set of transformations that can be applied to them. So </div>, function, background-color, utf-8 and several thousand other things can be referenced without ever appearing in the compressed stream.
The dictionary matters less than the articles say
The static dictionary gets most of the attention in write-ups about Brotli, including the previous version of this one, and it deserves less.
It is genuinely valuable for small files. A 2KB HTML fragment has very little internal redundancy for LZ77 to work with, so having a pre-shared vocabulary is a large relative win — this is why Brotli's advantage is proportionally biggest on tiny responses.
On a 900KB minified bundle, the dictionary is 122KB of vocabulary against a file that contains vastly more internal repetition than the dictionary could ever supply. The window size is doing the work; the dictionary is a rounding error. If you want to test this yourself, compress a large bundle at level 11 and compare against a Brotli build with the dictionary disabled. The difference is under a percent.
Where it does matter and nobody uses it: custom dictionaries. Brotli supports supplying your own, and the compression-dictionary-transport work in Chrome lets you use a previous version of a file as the dictionary for the next one. For a store shipping a bundle that changes by 3% per release, this can turn a 280KB download into an 8KB delta. Chrome 130 shipped it in late 2024 and support elsewhere is still thin, so it is a thing to watch rather than a thing to deploy — but it is the most interesting development in HTTP compression in a decade and it will make the gzip-versus-Brotli argument look quaint.
3. The Eleven Levels, Measured
Brotli has quality levels 0 to 11. Gzip has 1 to 9. The numbers are not comparable across algorithms and the relationship between level and cost is wildly non-linear, which is the part that catches people.
Here is a measurement I ran on a real production bundle — a 1.14MB minified Vue application from the distributor's storefront — on a single core of a c6i instance. Your numbers will differ; the shape will not.
| Setting | Output | vs gzip -6 | Encode time | Decode time |
|---|---|---|---|---|
| uncompressed | 1,167 KB | — | — | — |
| gzip -1 | 367 KB | +11% | 18 ms | 4 ms |
| gzip -6 | 331 KB | baseline | 41 ms | 4 ms |
| gzip -9 | 328 KB | −1% | 112 ms | 4 ms |
| brotli -1 | 346 KB | +5% | 14 ms | 3 ms |
| brotli -4 | 304 KB | −8% | 29 ms | 3 ms |
| brotli -5 | 289 KB | −13% | 48 ms | 3 ms |
| brotli -6 | 285 KB | −14% | 71 ms | 3 ms |
| brotli -9 | 276 KB | −17% | 310 ms | 3 ms |
| brotli -11 | 259 KB | −22% | 2,140 ms | 3 ms |
Read the encode column. Level 11 is forty-five times slower than level 5 and buys 30KB. That ratio is the entire argument of this article.
Three other things worth pulling out of that table.
Decode time barely moves. Brotli decompresses at roughly gzip speed regardless of the level it was encoded at, and often slightly faster. This asymmetry is the whole reason precompression works: you can spend two seconds encoding once and every client pays three milliseconds decoding.
gzip -9 is nearly pointless. It is 2.7× the CPU of gzip -6 for 1% fewer bytes. If you are still running gzip dynamically, run it at 5 or 6. Nobody should be paying for gzip -9 at request time, and quite a lot of people are because 9 looks thorough.
Brotli level 4 is roughly gzip -6 speed with an 8% better result. This is the important comparison, because it means the swap from dynamic gzip to dynamic Brotli is free — you are not trading CPU for bytes, you are getting bytes for the same CPU. That is not true at level 11 and the confusion between those two facts is why the distributor's site got slower.
4. Dynamic Compression: Picking A Level You Can Defend
Dynamic compression means the response is generated per request and must be compressed in the request path. HTML from your application, GraphQL and REST payloads, search results, anything personalised.
The relevant question is not "which level compresses best" but "at what point does the compression time exceed the transfer time it saves". That crossover depends on the client's bandwidth, and the client's bandwidth is not something your server knows.
Work it through for a 90KB HTML response. Level 4 gives you roughly 22KB, level 11 roughly 19KB — a 3KB difference. On a 10 Mbps mobile connection, 3KB is about 2.4ms of transfer. On a 1 Gbps fibre line it is 0.024ms. The extra encode time to get those 3KB is somewhere between 40 and 200ms depending on the response.
You are never winning that trade. Not on any connection a customer of yours is using.
So: level 4 or 5 for dynamic content, and I lean towards 4 on CPU-constrained origins and 5 where there is headroom. Both comfortably beat gzip -6 on output size at comparable cost. Above 5, the curve turns against you sharply and the bytes you gain are not bytes anyone waits for.
There is one exception worth naming. If your responses are enormous — a 4MB product feed, a bulk export endpoint, a sitemap index — and they are fetched by machines over long-lived connections, the arithmetic flips, because the transfer time is now seconds rather than milliseconds. Compress those at 9 or 11, and cache the result so you only do it once. Do not raise the global level to serve them; use a location-specific override.
5. Static Precompression: Where The Real Win Is
Every argument above disappears if the compression happens at build time, because build-time CPU is free. Nobody is waiting for it.
Your fingerprinted assets — app.7f3c2a.js, main.9b21e4.css, your fonts if they are not already compressed formats, your SVG sprites — are immutable. Compressing them at request time, over and over, for years, at the cheap level, is strictly worse than compressing them once at level 11 during the build and having the server read the result off disk.
Two effects, and the second one is bigger than the first.
You get the level 11 output: 22% smaller than gzip against 13% at level 5 on that bundle. And you spend zero CPU per request. On a busy origin the second effect is what shows up in your monitoring, because compression was quietly consuming a real share of your worker time.
Here is the script I use. It is a bash script rather than a build plugin deliberately, because it works identically for Webpack, Vite, Rollup, a Magento static content deploy, and a directory of files somebody produced by hand.
#!/usr/bin/env bash
# precompress.sh — build-time Brotli and gzip for immutable assets.
# Runs after minification, after tree-shaking, after CSS purging.
# Compressing pre-minified output wastes CPU and produces worse ratios.
set -euo pipefail
TARGET="${1:-./dist}"
# Below this size, compression is usually a net loss once you count
# the framing overhead and the CPU on both ends. 1KB is a reasonable
# floor; measure your own if you care.
MIN_BYTES=1024
echo "precompressing ${TARGET}"
# Note: no .woff2 here. WOFF2 is already Brotli-compressed internally;
# recompressing it produces a larger file and costs you a request path
# decision for nothing. Same for .png, .jpg, .webp, .avif, .mp4, .zip.
mapfile -d '' FILES < <(
find "$TARGET" -type f \
\( -name '*.js' -o -name '*.mjs' -o -name '*.css' \
-o -name '*.html' -o -name '*.svg' -o -name '*.json' \
-o -name '*.xml' -o -name '*.txt' -o -name '*.map' \) \
-size +${MIN_BYTES}c -print0
)
compress_one() {
local f="$1"
# -Z is level 11, -k keeps the original, -f overwrites a stale .br
brotli -Z -k -f "$f"
# zopfli produces gzip-compatible output 3-8% smaller than gzip -9
# for a lot more CPU, which at build time is exactly the trade we want.
if command -v zopfli >/dev/null 2>&1; then
zopfli --i15 "$f"
else
gzip -9 -k -f "$f"
fi
# Sanity check: if compression made it bigger, throw the result away
# rather than shipping a file the server will happily serve.
for ext in br gz; do
if [ -f "$f.$ext" ] && [ "$(stat -c%s "$f.$ext")" -ge "$(stat -c%s "$f")" ]; then
rm -f "$f.$ext"
fi
done
}
export -f compress_one
printf '%s\0' "${FILES[@]}" \
| xargs -0 -P "$(nproc)" -I{} bash -c 'compress_one "$@"' _ {}
# Report, because a build step with no output is a build step that
# silently stops working.
orig=$(du -sb "$TARGET" --exclude='*.br' --exclude='*.gz' | cut -f1)
br=$(find "$TARGET" -name '*.br' -printf '%s\n' | paste -sd+ | bc)
printf 'precompressed %d files: %s -> %s brotli\n' \
"${#FILES[@]}" "$(numfmt --to=iec "$orig")" "$(numfmt --to=iec "${br:-0}")"
The zopfli branch is worth explaining. Zopfli produces DEFLATE output — so it is served as ordinary gzip and every client understands it — but searches much harder, giving 3–8% smaller files than gzip -9 at maybe 80× the CPU. At build time that is a trade you should take every single time, and almost nobody does. The clients that fall back to gzip are the oldest and slowest ones on your site; they benefit most from the extra few percent.
The size sanity check at the end has saved me twice. A small JSON file and an already-compressed SVG both grew under compression, and Nginx will cheerfully serve a .br that is larger than the original because it has no reason to check.
Doing it in the bundler instead
If you would rather keep it in the build tool, the Vite version is three lines and does the same job for the common case:
// vite.config.js
import { defineConfig } from 'vite';
import compression from 'vite-plugin-compression';
export default defineConfig({
plugins: [
// Two passes: one for Brotli, one for gzip. Both keep the original,
// because a client without either still needs something to fetch.
compression({
algorithm: 'brotliCompress',
ext: '.br',
threshold: 1024,
compressionOptions: {
params: {
// 11 — this runs at build time, so spend everything.
[require('zlib').constants.BROTLI_PARAM_QUALITY]: 11,
// Telling Brotli the input is text improves the model slightly.
[require('zlib').constants.BROTLI_PARAM_MODE]:
require('zlib').constants.BROTLI_MODE_TEXT,
},
},
deleteOriginFile: false,
}),
compression({ algorithm: 'gzip', ext: '.gz', threshold: 1024 }),
],
});
I still prefer the script, for one specific reason: the build plugin only sees files the bundler emitted. It misses everything in your public directory, everything your CMS deploys, and everything a second build step produces. The script walks the output directory and gets all of it.
6. Wiring It Into Nginx
The ngx_brotli module is not compiled into most distribution packages, so you either build Nginx with it, use a distribution that ships it as a dynamic module, or use OpenResty. Since Nginx 1.11.5 it can be loaded dynamically, which is the least painful route.
# /etc/nginx/nginx.conf — module load, before the http block
load_module modules/ngx_http_brotli_filter_module.so; # dynamic compression
load_module modules/ngx_http_brotli_static_module.so; # serves .br from disk
http {
# ------------------------------------------------------------------
# Static: serve a pre-built .br if one exists next to the file.
# This is checked BEFORE the dynamic filter runs, so a precompressed
# asset never touches the compressor.
# ------------------------------------------------------------------
brotli_static on;
gzip_static on;
# ------------------------------------------------------------------
# Dynamic: level 5, for responses generated per request.
# ------------------------------------------------------------------
brotli on;
brotli_comp_level 5;
brotli_min_length 1024;
# 16MB window. Costs memory per active connection being compressed;
# if you are memory-constrained with high concurrency, 4m is a
# reasonable compromise and loses very little on typical responses.
brotli_window 16m;
brotli_buffers 16 8k;
brotli_types
application/atom+xml
application/javascript
application/json
application/ld+json
application/manifest+json
application/rss+xml
application/vnd.api+json
application/xml
font/ttf
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/javascript
text/plain
text/xml;
# text/html is always compressed and must NOT be listed; listing it
# is harmless in nginx but it is the line people add when they think
# HTML is not being compressed, and it is never the real cause.
# Keep gzip configured as the fallback. A client that sends only
# "gzip" in Accept-Encoding must still get a compressed response.
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types text/css text/plain text/xml application/javascript
application/json image/svg+xml;
# gzip_vary emits "Vary: Accept-Encoding". Required for correctness
# with any shared cache in front. ngx_brotli sets it too.
}
One detail that costs people an afternoon: brotli_static on only serves file.br if that exact file exists. It does not compress on demand and cache the result, and it does not fall through to gzip_static if the .br is missing — it falls through to the dynamic filter, which will compress the original at level 5. So a build that fails to produce .br files does not break anything visibly; it just silently costs you the level-11 output and some CPU. That is exactly the kind of regression that survives for a year.
Assert on it in CI:
# Verify the deployed assets are actually being served precompressed.
# A 200 with content-encoding: br proves nothing on its own — the
# dynamic filter produces that too. Compare the byte count against
# the .br file we built.
URL="https://www.example.com/assets/app.7f3c2a.js"
served=$(curl -s -H 'Accept-Encoding: br' -o /dev/null -w '%{size_download}' "$URL")
built=$(stat -c%s dist/assets/app.7f3c2a.js.br)
# Allow a little slack for framing differences between builds.
if [ "$served" -gt $(( built + 512 )) ]; then
echo "FAIL: served ${served}B but built ${built}B — brotli_static is not hitting"
exit 1
fi
echo "ok: precompressed asset served (${served}B)"
7. Apache, And The Node Case
Apache's mod_brotli has been in core since 2.4.26 and is simpler to enable, but it has no static equivalent to brotli_static. You do it with rewrite rules, which is fiddly and works:
<IfModule mod_rewrite.c>
RewriteEngine On
# Only for clients that asked for br
RewriteCond %{HTTP:Accept-Encoding} br
# Only if the precompressed file actually exists
RewriteCond %{REQUEST_FILENAME}.br -f
RewriteRule ^(.*)$ $1.br [L]
# The rewritten file must carry the right headers, or the browser
# will try to execute a Brotli stream as JavaScript.
<FilesMatch "\.js\.br$">
Header set Content-Encoding br
Header append Vary Accept-Encoding
ForceType application/javascript
</FilesMatch>
<FilesMatch "\.css\.br$">
Header set Content-Encoding br
Header append Vary Accept-Encoding
ForceType text/css
</FilesMatch>
</IfModule>
The ForceType lines are the bit that gets forgotten, and the symptom is a page that renders unstyled with a console full of MIME type errors.
For Node, do not compress in Node. The compression middleware runs on the event loop, and Brotli at any level is enough work to block it measurably under load. Put Nginx or your CDN in front and let it handle encoding. If you genuinely must, use the streaming API with an explicit low quality and a size hint:
// If you must compress in-process, be explicit about everything.
// The default quality for zlib.brotliCompress is 11, which is a
// catastrophic default for a request handler.
const zlib = require('zlib');
const brotliOptions = (byteLength) => ({
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 4,
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
// Telling the encoder the size lets it choose a window without
// over-allocating for a small response.
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: byteLength,
},
});
app.use(require('compression')({
threshold: 1024,
brotli: { enabled: true, zlib: brotliOptions(0) },
}));
8. What Compresses Badly, And What Not To Touch
Compression is not free and it is not universally beneficial. Four categories to leave alone.
Already-compressed binary formats. JPEG, PNG, WebP, AVIF, MP4, WOFF2, ZIP, PDF in most cases. These have had their redundancy removed already. Running Brotli over a JPEG costs CPU and produces a file 0.1% smaller or, frequently, slightly larger. WOFF2 is the one people get wrong most often, because it is a font and fonts feel like they should compress — WOFF2 is Brotli internally, so you are compressing a Brotli stream.
Very small responses. There is per-stream overhead: headers, a window setup, and for tiny inputs the framing can exceed the saving. Below about 500 bytes you are usually losing, and between 500 and 1500 bytes it depends on the content. A 1024-byte floor is a defensible default and I have never seen it be wrong enough to matter.
High-entropy text. Base64 blobs, encrypted payloads, UUID-heavy JSON, hashes. Base64 is a particular trap because it looks like text and is treated as text, but it encodes binary data at 4/3 expansion — the underlying entropy is high and Brotli will get very little back. If you have a JSON API returning base64 images inline, the compression ratio on those responses will be poor and the fix is not a better compressor, it is not inlining images in JSON.
Already-encoded upstream responses. If your origin sends Content-Encoding: gzip and your proxy decompresses it to recompress it as Brotli, you are paying for a decompress and a compress on every request to save a few kilobytes. Configure the proxy to pass encoded content through, or to request identity from origin and encode once. Either is fine; doing both halves of the round trip is not.
The general test is whether the content has structure a back-reference can exploit. Human-readable text and source code: enormously. Machine-generated markup: even more, because it repeats. Random or already-packed bytes: not at all.
9. The Fallback Path Still Matters
Brotli over HTTPS has been supported in Chrome since version 50 and Firefox since 44, both in 2016, and in Safari since 11. Global support is somewhere north of 96%. So the fallback is a small share of traffic — and it is a share that skews towards old devices, corporate proxies and unusual clients, which is to say the visitors already having the worst time.
Two rules.
Never serve Brotli to a client that did not ask for it. This sounds obvious and it goes wrong when a cache stores a Brotli response and serves it to a gzip-only client, which is a Vary: Accept-Encoding failure. The symptom is a page of binary garbage. Make sure every layer emits Vary: Accept-Encoding and that your CDN honours it.
Keep the gzip files. Precompress both. The disk cost is trivial and the alternative is that your oldest clients get uncompressed responses, which for a 1.1MB bundle is a 3.5× penalty applied precisely to the people with the worst connections.
Also worth knowing: Brotli is only negotiated over HTTPS in every major browser. Over plain HTTP, browsers do not advertise br at all. This was a deliberate decision to avoid middlebox breakage. If you are testing against a local HTTP dev server and wondering why Brotli never kicks in, that is why, and it is the single most common false alarm I get asked about.
10. Compression And Your CDN
The interaction here produces more waste than any other part of the topic.
Most CDNs will compress on your behalf at the edge, typically at Brotli level 4 or 5. That is a sensible default and it is the wrong thing to happen to an asset you already compressed at level 11.
The failure mode is usually one of these. The origin sends an uncompressed asset, the edge compresses at level 5, and your build-time level 11 output is never served to anybody. Or the origin sends level 11 Brotli, the edge decompresses to apply an HTML transformation, and recompresses at level 5. Or the edge re-encodes based on a normalised Accept-Encoding that does not match what the client sent.
What you want is: pass through what the origin sent for immutable asset paths, and compress at the edge only for dynamic responses the origin did not encode.
# Cloudflare configuration, expressed as rules:
#
# 1. Cache Rule on /assets/*
# - Respect origin Content-Encoding (do not re-compress)
# - Disable Auto Minify (it decompresses to rewrite, then recompresses)
# - Edge TTL 1 year, respect origin fingerprinting
#
# 2. Everything else
# - Brotli enabled, edge compresses dynamic responses
#
# The trap: "Auto Minify" and any HTML-rewriting feature (Rocket Loader,
# Email Obfuscation, automatic Early Hints injection) all require the
# edge to decode the body. Any one of them silently discards your
# level-11 encoding on the paths it applies to.
Then verify it, because every provider's UI describes this differently and none of them describe it clearly:
# Compare what the origin produced against what the edge delivered.
# If the edge byte count is materially larger, it re-encoded.
ASSET="/assets/app.7f3c2a.js"
origin=$(curl -s -H 'Accept-Encoding: br' -H 'Host: www.example.com' \
-o /dev/null -w '%{size_download}' "https://origin.example.com${ASSET}")
edge=$(curl -s -H 'Accept-Encoding: br' \
-o /dev/null -w '%{size_download}' "https://www.example.com${ASSET}")
echo "origin=${origin}B edge=${edge}B delta=$(( edge - origin ))B"
# A positive delta of more than a few hundred bytes means the edge
# re-compressed at a lower level. Chase it.
Getting this right on the distributor's site recovered 31KB per page load that they had been building and then throwing away for eight months. Nobody had checked, because everything reported content-encoding: br and everybody assumed that meant the build output was reaching customers. It said the response was Brotli. It did not say whose Brotli.
This is also worth a thought when you are configuring cache keys at the edge, since Vary: Accept-Encoding creates a cache variant per encoding and a mangled header from a proxy can create dozens.
11. The Long Tail: Fonts, SVG, And Everything The Script Missed
Bundles get all the attention because they are the biggest single file. The aggregate of everything else is frequently larger, and it is where precompression quietly does not happen.
SVG. Text, and therefore extremely compressible — an icon sprite typically drops 70–80%. It is also the format most likely to be missing from a brotli_types list, because it lives under image/ and people filter images out of their compression rules on the reasonable theory that images are already compressed. image/svg+xml must be in the list explicitly. I find this missing on maybe half the sites I look at.
Fonts. WOFF2 is Brotli inside and must be left alone. Older WOFF is gzip-compressed inside and should also be left alone. Bare TTF and OTF are uncompressed and compress well — 40% or so — but if you are still serving raw TTF to browsers in 2026, converting to WOFF2 saves far more than any compression setting will.
Source maps. Big, extremely compressible, and requested only by developers and error-reporting tools. Precompress them, and check whether you are serving them publicly at all — a 4MB source map fetched by a crawler is a bandwidth line item nobody notices.
JSON that is not an API response. Translation files, product feeds, configuration blobs, the search index a client-side search library downloads. These are often served from a static path and often miss the compression rules because they were added by a different team at a different time. A 900KB Lunr index compresses to about 190KB and I have found it uncompressed twice.
Your sitemap. Big, pure XML, fetched by crawlers repeatedly. Compresses by 85% or more. Google supports gzipped sitemaps served with the right headers and it is a genuinely free saving.
The way to find all of these is not to think about it, but to sort your resource timing table by decodedBodySize − transferSize and look at anything where the ratio is close to 1.0 and the file is not a known binary format. Every entry on that list is either incompressible or misconfigured, and you can tell which in about five seconds by looking at the extension.
12. Rolling It Out Without Breaking Something
Compression changes have an unusual risk profile: they almost never fail loudly. A misconfiguration produces bytes that are slightly wrong for a small subset of clients, or slightly larger than intended for everyone, and neither raises an alarm.
The failure modes I have actually seen, in order of how long they took to find:
Double encoding. A proxy compresses a response that was already compressed and sets Content-Encoding: br once. The client decodes once and gets a Brotli stream where it expected JavaScript. Symptom: a completely broken page for everyone, found in minutes. This is the good kind of failure.
Missing Vary. A shared cache stores the Brotli variant and serves it to a gzip-only client. Symptom: broken pages for 3% of visitors, mostly on corporate networks, reported as "the site doesn't work here" and impossible to reproduce. This one took a client six weeks.
Stale .br files. The build regenerates app.js but the precompression step fails silently, leaving yesterday's app.js.br next to today's app.js. Nginx serves the stale one because it exists. Symptom: a deploy that visibly did not deploy, for Brotli-capable clients only. The -f flag in the script and a check that the .br is newer than its source both guard against this; use both.
Correct but pointless. Everything works, everything reports content-encoding: br, and the level-11 build output is being discarded at the edge. Symptom: none whatsoever. This is the one that ran for eight months.
So roll it out in an order that makes each step verifiable. Static precompression first, with the CI byte-size assertion, because that step has a hard pass/fail. Then the dynamic level, one server at a time if you can, watching CPU rather than watching byte counts. Then the CDN pass-through rules, verified with the origin-versus-edge comparison. Do not do all three in one change, because if the result is worse you will not know which one did it — which is precisely how the distributor lost eleven days.
And keep the assertion running afterwards. Compression is the sort of thing that works on Tuesday, gets broken by an unrelated infrastructure change on a Thursday four months later, and is discovered the following year.
13. Streaming, TTFB, And The Flush Trap
Something the byte-count discussion misses entirely.
Compression buffers. To compress well, the encoder wants to see a chunk of input before it emits anything. For a server that streams HTML — sending the head early so the browser can start fetching CSS while the body is still being assembled — this directly conflicts with the compressor's desire to accumulate.
On a page using early flushing, an aggressive compression buffer can hold the first flush until the buffer fills, undoing the entire point of streaming. Your TTFB gets worse and your byte count gets slightly better.
Nginx's brotli_buffers 16 8k means 16 buffers of 8KB. If your early-flush head section is 3KB, it sits in a buffer waiting. The fix is to flush explicitly at the application level and ensure the proxy respects it — proxy_buffering off for the streaming location, or smaller buffers.
This matters if you are doing streamed server-side rendering, and it does not matter at all otherwise. I mention it because I spent a day on it once, chasing a TTFB regression that appeared when we enabled compression on a Next.js streaming route, and the cause was not obvious from any dashboard.
14. Zstandard, Briefly
You will see zstd mentioned. It is Facebook's algorithm, it is excellent, and it occupies a different point on the curve: much faster encoding than Brotli at similar ratios, which makes it superb for dynamic content.
Chrome shipped Content-Encoding: zstd in version 123, March 2024. Firefox followed in 126. Safari has not. So you are looking at maybe 70% of traffic, which means running it as a third encoding alongside Brotli and gzip, with a third set of precompressed files and a third cache variant.
My position: not yet for storefronts. The gain over Brotli on static assets is small — zstd's advantage is speed, and speed is precisely what you do not need when you compress at build time. For dynamic responses at scale on an API, where encode CPU is the binding constraint, it is genuinely compelling and I would use it. For a shop, add the complexity when Safari ships it.
15. Compression And Security: BREACH
Brief but not skippable.
The BREACH attack, published in 2013, exploits HTTP compression to extract secrets. If a response contains both a secret (a CSRF token) and attacker-controlled input (a reflected search term), the attacker can observe compressed response sizes across many requests and infer the secret one character at a time, because a guess that matches the token compresses slightly better.
This is real, it applies to Brotli exactly as it applies to gzip, and turning compression off is not the mitigation anyone actually uses. What people do instead:
Do not put CSRF tokens in responses that also reflect user input. Mask the token per request — XOR it with a random value that changes each time, so the compressed size does not correlate. Rate-limit, since the attack needs thousands of requests. And separate secrets from reflected content into different responses where you can.
Most modern frameworks mask CSRF tokens by default now. Rails does, Django does, Laravel does. If you have hand-rolled session handling, this is worth an hour of checking before you turn compression up on authenticated pages.
16. Measuring The Byte Difference
Measure the encoding, not the assumption. Every layer between your build and your customer can quietly change it.
#!/usr/bin/env bash
# What is actually on the wire, per encoding, for a given URL.
# Run against production. The gap between the br and gzip columns is
# your real Brotli benefit; the gap to identity is your compression
# benefit overall, which is the much bigger number.
URL="${1:?usage: measure.sh URL}"
for enc in "br" "gzip" "identity"; do
read -r bytes encoding <<<"$(
curl -s -H "Accept-Encoding: ${enc}" -o /dev/null \
-w '%{size_download} ' "$URL"
curl -s -H "Accept-Encoding: ${enc}" -o /dev/null -D - "$URL" \
| awk 'tolower($1) == "content-encoding:" { print $2 }' | tr -d '\r'
)"
printf '%-10s %8s bytes (served as: %s)\n' \
"$enc" "$bytes" "${encoding:-none}"
done
Run that against your five largest assets and your three most-requested HTML templates. That is ten numbers and it will take four minutes, and it is a better picture of your compression posture than any tool will give you.
For the site-wide view, the resource timing API gives you transfer size and decoded size on every request, and the ratio between them is your real compression rate on real content:
// Actual compression ratio by resource type, from a real page load.
// decodedBodySize is what the parser saw; transferSize is what crossed
// the network including headers. Anything with a ratio near 1.0 is
// either incompressible or is not being compressed — check which.
const rows = performance.getEntriesByType('resource')
.filter(e => e.decodedBodySize > 0 && e.transferSize > 0)
.map(e => ({
file: new URL(e.name).pathname.split('/').pop(),
type: e.initiatorType,
wire: Math.round(e.transferSize / 1024),
decoded: Math.round(e.decodedBodySize / 1024),
ratio: +(e.transferSize / e.decodedBodySize).toFixed(2),
}))
// Biggest absolute saving opportunity first, not biggest ratio.
.sort((a, b) => (b.decoded - b.wire) - (a.decoded - a.wire));
console.table(rows.slice(0, 25));
Sort by absolute bytes rather than by ratio. A file with a terrible ratio that is 2KB does not matter; a file with a decent ratio that is 400KB does.
17. Measuring The Latency Difference, Which Is Smaller
Here is the part I want to be honest about, because it is where compression articles overclaim.
Saving 40KB does not save 40KB worth of time. It saves 40KB divided by the effective throughput at that moment, and on a modern connection that number is small. At 10 Mbps, 40KB is about 32ms. At 40 Mbps it is 8ms.
Where it is worth more than the arithmetic suggests:
Early in the connection. TCP slow start means the first few round trips carry very little. A response that fits in the initial congestion window — roughly 14KB — arrives in one round trip. One that is 20KB takes two. Compressing an 18KB HTML document down to 12KB can therefore save an entire RTT, which on mobile is 60–150ms. That is a much bigger effect than the byte count implies, and it is why compressing HTML matters more than compressing a large bundle that was always going to take several round trips.
On genuinely slow connections. The 40KB that costs 8ms on fibre costs 640ms on a 500 Kbps rural connection. Your p95 mobile users are the ones who benefit, and they are invisible in your median.
On metered data. Not a latency effect at all, but real. A customer on a capped plan cares about your 30% reduction in a way your dashboard cannot see.
Where it is worth less than claimed: for a fast connection fetching an asset that was already cached, which is most repeat visits. Compression only helps the first fetch. If your LCP problem is a render-blocking script or a 900ms server response, compression will not touch it, and I have watched a team spend a fortnight on compression tuning while a 1.4MB unoptimised hero image sat above the fold.
Do compression because it is cheap, correct, and permanent. Do not expect it to be the thing that fixes a slow site.
18. A Worked Example
The distributor. Magento 2.4 on AWS, four application servers behind an ALB, Cloudflare in front, roughly 340,000 sessions a month, B2B with a large logged-in population — which matters, because logged-in traffic bypasses the full page cache and is therefore dynamic.
Where they started. Gzip level 6 dynamic on everything, no static precompression, Cloudflare compressing at the edge on top. Average HTML transfer 78KB. Main bundle 331KB on the wire. p75 TTFB 340ms.
What went wrong first. The brotli_comp_level 11 change described at the top. Deployed on a Thursday, TTFB regression visible in RUM by Friday, but attributed to a Magento patch that went out the same day. It took eleven days to find, because nobody suspected the compression change — it was a one-line config edit that everyone agreed was an improvement. Application server CPU had gone from 61% to 78% average and 94% at peak, which in retrospect was the obvious signal and which nobody was watching because CPU had never been interesting before.
The lesson I took from that is not about Brotli. It is that a change which improves one metric and degrades another will be attributed to whatever else shipped that week, and one-line config changes are the hardest to find because they do not look like deploys.
What we actually did. Dynamic Brotli at level 5, replacing gzip 6. Byte-for-byte, dynamic HTML went from 78KB to 68KB with no measurable CPU change against the original gzip baseline.
Static precompression at level 11 in the Docker build, using the script above, applied to Magento's static content deploy output. This is roughly 4,800 files on a stock-plus-theme install and it added 90 seconds to the build.
Zopfli for the gzip fallbacks, which gained another 5% on that path.
Cloudflare cache rules to pass through origin encoding on /static/* and /media/*, and Auto Minify turned off entirely — it was decompressing and recompressing every asset, and its actual minification gain on already-minified files was 0.1%.
Results, measured over three weeks. Main bundle 331KB to 259KB on the wire, a 22% reduction. Total transfer on a cold product page load 1.34MB to 1.09MB. Application server CPU down 9% from the original gzip baseline, because static assets stopped being compressed at request time. p75 TTFB 340ms to 318ms. p75 LCP 2.9s to 2.7s.
The honest assessment. A 200ms LCP improvement for a day and a half of work is a good return, and it is not a transformation. In the same quarter, deferring three marketing scripts bought 600ms and took an afternoon. Compression is table stakes: you should have it, it should be configured properly, and it is unlikely to be your biggest available win.
What I would do differently. Start with the static precompression, not the dynamic level. The static side has no downside, needs no tuning, and delivered most of the benefit. The dynamic level is where the risk lives and where I created a two-week regression. If I had done them in the other order, the CPU signal would have been unambiguous.
19. Questions People Ask
"Should I use Brotli or gzip?" Both. Brotli for the 96% of clients that support it, gzip precompressed for the rest. This is not an either/or and framing it as one is what leads people to drop the fallback.
"What level should I use?" 11 for anything compressed at build time. 4 or 5 for anything compressed in the request path. There is no situation on a storefront where 7 through 10 is the right answer — you are either paying request-time CPU, in which case go lower, or you are not, in which case go to 11.
"My CDN says it does Brotli. Am I done?" No. Edge Brotli is level 4 or 5 and it applies to what your origin sent. You still want build-time level 11 on immutable assets, and you want to verify the edge is passing it through rather than re-encoding.
"Does compression affect SEO?" Indirectly and weakly, via response time and page weight, the same as any performance work. There is no compression signal. What is real: a smaller page is crawled faster and Googlebot supports Brotli, so your crawl budget goes fractionally further. On a large catalogue that is worth something. It will not move a ranking on its own.
"Is it worth compressing already-minified JavaScript?" Very much so — they are orthogonal. Minification removes whitespace and shortens identifiers; compression removes redundancy. Minified code compresses slightly worse in ratio terms because some redundancy is already gone, but the absolute output is far smaller. Do both, minify first.
"What about compressing images?" Different problem entirely. Image bytes are addressed by format and by sizing, not by transport compression. Serving an AVIF instead of a JPEG saves an order of magnitude more than any Brotli setting; the format and srcset work is where that effort belongs.
"How much disk does precompression cost?" Roughly 30% of your asset directory for the .br files plus 35% for the .gz, so call it 1.65× your build output. On a build that produces 200MB, that is 130MB. It is not a consideration.
"Can I precompress dynamic content?" Not directly, but you can cache the compressed result, which amounts to the same thing. Nginx's proxy cache stores the compressed response, so a cached page is compressed once and served many times. This is another reason a decent cache hit rate makes every other optimisation cheaper.
"Why is my JSON API not compressed?" Nearly always a missing MIME type. application/json is in most default lists but application/vnd.api+json, application/ld+json and custom vendor types are not, and a GraphQL endpoint returning application/graphql-response+json will sail past a stock configuration uncompressed. Check the actual Content-Type your API emits against your brotli_types list.
"Does HTTP/2 or HTTP/3 change any of this?" Header compression changes — HPACK and QPACK compress headers, which is separate from body compression and happens automatically. Body compression is unchanged. What does change is that multiplexing removes the incentive to concatenate everything into one giant bundle, and smaller files compress slightly worse individually, so there is a mild tension there. Not enough to change how you bundle.
20. What I'd Do First
Check what is actually on the wire before changing anything. Run the three-encoding curl comparison against your largest bundle and your busiest HTML template. You may find you are already fine, or you may find you are shipping something uncompressed, and either answer saves you a week.
Add build-time precompression at level 11 for every immutable asset, with gzip fallbacks via zopfli. No tuning, no risk, no request-path cost. This is the change with the best ratio of benefit to danger in the whole article and it is the one people do last.
Turn on brotli_static and gzip_static, then assert in CI that the served bytes match the built bytes. Without that assertion you will not notice when it stops working, and it will stop working.
Only then look at the dynamic level. Set it to 5, watch application CPU for a week, and drop to 4 if CPU is tight. Do not go higher, whatever the level number implies about quality.
Audit your CDN for re-encoding. Compare origin bytes against edge bytes for one asset. If they differ by more than a few hundred bytes, your build-time compression is being discarded and you have been paying for it in build minutes for nothing.
Check your MIME type list against what your APIs actually return, particularly GraphQL and anything with a vendor content type.
Then measure LCP and TTFB in the field, before and after, and be prepared for the improvement to be modest. Compression is hygiene. It should be right, it should stay right, and the day you find yourself tuning it for a third week is the day to go and look at your images instead.
Suggested & Related Reading
Explore related engineering guides from Kenneth D'Silva:
-
Performance Optimization for Magento & Shopify Stores
Network performance optimizations and latency reduction techniques.
-
Critical CSS Extraction and Inline Delivery
Techniques for isolating and delivering First Contentful Paint CSS immediately.
-
CDN Caching Strategies for Core Web Vitals
Edge caching mechanisms to consistently pass Core Web Vitals metrics.