Gzip vs Brotli: HTTP Compression Compared
Compare gzip and Brotli for HTTP compression: negotiation, levels, static vs dynamic, Vary and CDN pitfalls, server configs and curl checks, with measured sizes.

Your page is minified, your images are optimised, and the network panel still shows a fat JavaScript bundle and a slow API call. Before you rewrite anything, check one thing: is the server compressing text responses, and with which algorithm? Gzip has been the default answer for decades. Brotli is the newer one, and it usually produces smaller files. Choosing between them, and configuring either safely, is less obvious than it looks. This guide is for developers, DevOps engineers and site owners who run their own servers, proxies or CDN settings. It explains how the browser and server agree on an encoding, how gzip and Brotli differ inside, what compression levels really cost, when to compress ahead of time, which files to leave alone, and how to verify everything with curl. Every number in it was measured in a sandbox on samples you can reproduce.
What HTTP compression is, and a short history
HTTP compression is the practice of encoding a response body with a general-purpose compression algorithm before sending it, and decoding it on the client. The page itself is unchanged. The bytes on the wire are different, and the browser restores the original before it parses anything. Because HTML, CSS, JavaScript, JSON, SVG and XML are text full of repeated patterns, they shrink dramatically. The MDN overview of HTTP compression describes the same idea and the headers involved.
Gzip is the format that made this universal. It wraps the DEFLATE algorithm with a small header and a checksum, and it is specified in RFC 1952. DEFLATE itself, which combines LZ77 back-references with Huffman coding, is described in RFC 1951. The format came from the Unix gzip tool, and the Wikipedia article on gzip covers its origins. Practically every HTTP client and server on earth can speak it, which is the main reason it is still everywhere.
Brotli is newer. It was designed at Google and is specified in RFC 7932. It started as part of work on compressing web fonts and was then published as a general-purpose format for HTTP content. Browsers added support over the following years, and it is now available in all current major browsers when the connection uses HTTPS. The Brotli article on Wikipedia gives a broader summary.
The two formats solve the same problem with different tools. Gzip is small, fast and ubiquitous. Brotli packs in more modelling machinery and a built-in dictionary, so it compresses text better, at the cost of more CPU at the highest settings. The rest of this guide is about making that trade deliberately instead of by default.
How content negotiation works
Compression is negotiated per request, so a server never has to guess what a client can decode. The exchange takes two headers.
- The client sends
Accept-Encodingwith the encodings it understands, for exampleAccept-Encoding: br, gzip. - The server picks one, compresses the body with it, and replies with
Content-Encoding: br(orgzip). - If the server picks none, it sends the body as is and omits
Content-Encoding.
The tokens are short names registered for the purpose: gzip, br, deflate, identity (meaning no transformation) and, in newer clients, zstd. The semantics are defined in RFC 9110, the HTTP semantics specification. Browsers send a list that reflects what they can actually decode, and they include br only on secure connections.
Quality values
Each token in Accept-Encoding can carry a weight, written ;q= followed by a number from 0 to 1. A header such as gzip;q=1.0, br;q=0.8, *;q=0.1 says the client prefers gzip, will accept Brotli at lower priority, and tolerates anything else only reluctantly. A value of q=0 means "never send me this". The wildcard * stands for any encoding not otherwise listed.
In practice, most servers ignore the weights and apply their own preference order, usually Brotli, then gzip, then nothing. That is reasonable, since browsers rarely vary the weights. But a correct implementation must treat q=0 as a refusal, and the example server later in this guide does.
The response side
A compressed response should carry three things:
Content-Encodingnaming the algorithm, so the client knows how to decode.Vary: Accept-Encoding, which tells caches that the body depends on the request header.- A
Content-Typethat still describes the original resource, such asapplication/json. The content type describes the decoded data, not the compressed bytes.
Note what is not compressed: the headers. HTTP/1.1 sends them as plain text. HTTP/2 and HTTP/3 compress them separately with their own header compression schemes, which are unrelated to Content-Encoding.
Content-Encoding is not Transfer-Encoding
Older documentation sometimes mixes these up. Content-Encoding is a property of the resource representation, so it survives caching and is visible to the application. Transfer-Encoding applies to one hop of the connection. In modern deployments, you will see compression expressed almost exclusively through Content-Encoding, and chunked framing through Transfer-Encoding: chunked in HTTP/1.1. A compressed streamed response commonly uses both: the body is compressed, and then sent in chunks.
How gzip works inside
Gzip's compression comes from DEFLATE, which applies two ideas in sequence.
Step one: LZ77 matching. The compressor slides a window of up to 32 KB over the input. When it sees a string that already appeared in the window, it replaces the repeat with a pair: how far back the earlier copy starts, and how long it is. In a JSON array of records, the key "inStock": appears hundreds of times, so after the first occurrence each repeat becomes a tiny reference.
Step two: Huffman coding. The stream of literals and back-references is then encoded with Huffman codes, where frequent symbols get shorter bit patterns. DEFLATE builds separate code tables for the data, and it can emit a new table per block when the statistics change.
That is the whole algorithm. It has no memory of anything beyond the 32 KB window, and it knows nothing about the kind of data it is compressing. A gzip file also has a ten-byte header, an optional set of fields, and an eight-byte trailer with a CRC-32 checksum and the original length, which matters for tiny inputs.
Levels 1 to 9
Gzip exposes levels from 1 (fastest) to 9 (smallest), with 6 as the common default in many tools. Levels change how hard the compressor searches for matches. Low levels try only a few candidates, higher levels examine many. The decoder does not care which level made the file, and decompression speed is roughly the same either way. That asymmetry is the key to everything about compression strategy: you pay at compression time, and the client pays almost nothing to decode.
Here is a measurement. The sample is a JSON array of 500 product records, each with a name, price, stock flag, tags, nested dimensions and a pseudo-random SKU, serialised compactly to 64,004 bytes. Gzip was run through Node.js zlib:
| Gzip level | Compressed bytes | Share of original |
|---|---|---|
| 1 | 10,814 | about 16.9% |
| 6 | 8,508 | about 13.3% |
| 9 | 8,046 | about 12.6% |
Going from level 1 to level 6 saved about 21 percent of the compressed size. Going from 6 to 9 saved about another 5 percent. The returns diminish quickly, which is why almost everyone sticks with the middle of the range for per-request compression.
How Brotli works inside
Brotli uses the same broad ideas as DEFLATE, LZ77-style matching and entropy coding, and then adds three significant improvements.
A built-in static dictionary
The Brotli format includes a fixed dictionary of roughly 120 KB, shared by every encoder and decoder, so it never travels over the network. It holds common words, phrases and fragments from natural languages, HTML, CSS and JavaScript, such as <!DOCTYPE, function, text/javascript and frequent English words. An encoder can reference an entry in this dictionary just as it would reference earlier input. This gives Brotli a head start on small files, where there is little earlier input to refer back to. The format also defines transforms that let one dictionary word match with a prefix or suffix, such as adding a capital letter or a trailing space.
The consequence is that Brotli is especially good at small and medium web assets, and the dictionary is tuned for web text. It does not help with random bytes, and it helps less with data that looks nothing like web content.
A much larger window
Gzip looks back at most 32 KB. Brotli's window is configurable and can reach up to 16 MiB in the format. A larger window lets the compressor find repeats across a whole large bundle, not just within the last few screens of text. Browsers limit the window size they will accept when decoding, so servers should stick to reasonable window settings for HTTP, and the default settings in common libraries do.
Context modelling
Brotli picks among several Huffman code tables depending on the context of each byte, such as whether the previous bytes looked like a letter, a digit or punctuation. The same literal then gets a different, better-fitted code in a different context. It is a richer entropy coder than DEFLATE's, and it explains much of the size win on text.
Quality levels 0 to 11
Brotli's quality parameter runs from 0 to 11. Low settings are fast and compare to gzip's low levels. The top level, 11, performs far more exhaustive searching and is very slow, which becomes the central practical fact about Brotli. Here is the same 64,004-byte JSON sample, compressed at several qualities with Node.js zlib.brotliCompressSync:
| Brotli quality | Compressed bytes | Share of original |
|---|---|---|
| 1 | 9,130 | about 14.3% |
| 4 | 7,730 | about 12.1% |
| 5 | 6,847 | about 10.7% |
| 9 | 6,537 | about 10.2% |
| 11 | 5,012 | about 7.8% |
Even quality 4 beat gzip level 9 on this sample (7,730 against 8,046 bytes). Quality 11 came in at about 62 percent of the size of gzip level 9. Those are measurements from one synthetic sample on one machine, so treat the shape as informative and the exact percentages as illustrative.
Gzip against Brotli: size, speed and the CPU trade-off
Size is half of the story. The other half is how long the compressor takes. To see it, the same kind of data was scaled up to 20,000 records, 2,191,880 bytes in all, and each setting was timed once on the sandbox machine:
| Setting | Compressed bytes | Time on this machine |
|---|---|---|
| gzip level 1 | 343,054 | 9 ms |
| gzip level 6 | 300,214 | 27 ms |
| gzip level 9 | 280,027 | 112 ms |
| Brotli quality 4 | 268,694 | 24 ms |
| Brotli quality 5 | 222,353 | 40 ms |
| Brotli quality 9 | 185,440 | 131 ms |
| Brotli quality 11 | 158,430 | 4,040 ms |
A single timing on a shared sandbox is noisy, so read it for orders of magnitude, not for precision. Three conclusions survive the noise.
First, Brotli at a moderate quality is competitive with gzip on speed and clearly better on size. Quality 5 here took roughly the time of gzip level 6 to gzip level 9 and produced a file about 26 percent smaller than gzip level 6.
Second, Brotli quality 11 is in a class of its own. It was about 150 times slower than gzip level 6 on this sample. It produced the smallest output by a wide margin, and it is the wrong setting for compressing a response while a user waits.
Third, the cost of decompression is not in the table, because it is small for both. Decoding is much cheaper than encoding for both formats, which is why the best strategy is to spend CPU once, ahead of time, on files that many users will download.
Where Brotli helps most
On HTML, CSS and JavaScript, which the dictionary was built for, Brotli typically produces visibly smaller files than gzip. On a synthetic HTML page of 300 repeated cards (41,552 bytes), the measurements were:
| Encoding | Compressed bytes |
|---|---|
| gzip level 6 | 1,909 |
| gzip level 9 | 1,915 |
| Brotli quality 5 | 1,028 |
| Brotli quality 11 | 898 |
That page is extremely repetitive, so the ratios are exaggerated compared with real sites. A real page has more variety, and real savings from switching to Brotli are usually more modest. The honest advice is to measure your own assets, using the tool and commands later in this guide.
Static pre-compression against dynamic compression
There are two moments at which you can compress a response, and the choice shapes your whole setup.
Dynamic compression happens on each request. The server generates the response, compresses it, and sends it. This is necessary for personalised HTML and API responses, since they differ per user or per call. Because the user is waiting, you need fast settings: gzip around the default, or Brotli around quality 4 to 6.
Static pre-compression happens once, at build or deploy time. You compress each asset to app.js.gz and app.js.br next to app.js, and the server simply streams the right file when asked. Since the work happens once, you can afford gzip level 9 and Brotli quality 11 without touching request latency. A CDN that stores the compressed files serves them with almost no CPU.
Here is a small Node.js script that performs pre-compression. I ran it on a generated stylesheet of 13,131 bytes:
const fs = require("fs");
const zlib = require("zlib");
const src = process.argv[2];
const data = fs.readFileSync(src);
fs.writeFileSync(src + ".gz", zlib.gzipSync(data, { level: 9 }));
fs.writeFileSync(
src + ".br",
zlib.brotliCompressSync(data, {
params: {
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: data.length,
},
})
);
for (const ext of ["", ".gz", ".br"]) {
console.log(src + ext, fs.statSync(src + ext).size);
}
The output for the sample stylesheet was:
app.css 13131
app.css.gz 1013
app.css.br 488
A check with Python's gzip module confirmed that the .gz file decompresses to exactly the original bytes. The stylesheet was machine-generated and extremely repetitive, so the absolute ratios are unrealistic. The point is the workflow: three files, each identical in meaning, chosen per request.
What each approach suits
| Resource | Best approach | Reason |
|---|---|---|
| Hashed JS and CSS bundles | Static, Brotli 11 plus gzip 9 | Built once, downloaded many times |
| Fonts such as WOFF2 | Usually none | Already compressed internally |
| Marketing HTML that rarely changes | Static or cached dynamic | Same bytes for everyone |
| Personalised HTML | Dynamic, moderate level | Differs per request |
| JSON API responses | Dynamic, moderate level | Differs per call |
| Large downloadable text exports | Dynamic, moderate level, or pre-built | Depends on reuse |
Many build tools can emit .gz and .br files as a post-build step, and many CDNs compress on the fly at the edge. Check what your platform already does before adding a step, because double handling wastes effort.
Browser support and why Brotli needs HTTPS
Gzip works everywhere, including very old clients, command-line tools and scripts. Brotli is supported by current browsers, but browsers advertise it only on HTTPS connections. Over plain HTTP, they send Accept-Encoding: gzip, deflate without br.
The widely repeated explanation is that older intermediaries on unencrypted connections, such as proxies, sometimes mangled responses with encodings they did not recognise. Encrypting the connection hides the content from those middleboxes, so new encodings can be deployed safely. Whatever the exact history, the practical consequences are simple:
- Serve your site over HTTPS, which you should be doing anyway, and Brotli becomes available.
- Always keep a gzip fallback. Some clients, scripts, older systems and test tools do not send
br. - Do not test Brotli by sending
Accept-Encoding: brto an HTTP-only address and expecting browsers to behave the same way.curlwill happily request it, becausecurlis not a browser, but your users will never see it on plain HTTP.
Because the client lists what it supports, a server that follows the protocol correctly serves everyone: Brotli to those who ask, gzip to the rest, and plain bytes to the remainder. That is the entire reason negotiation exists. Never hard-code Brotli without checking the request header.
Vary, caches and CDNs
When one URL can return different bytes depending on a request header, caches need to know. That is what Vary is for.
Suppose /app.js is available as Brotli and gzip. Without Vary: Accept-Encoding, a shared cache might store the Brotli version after the first request and send it to a later client that asked only for gzip, or the reverse. The result is a response the client cannot decode, which looks like garbled text or a decoding error. Setting Vary: Accept-Encoding tells the cache to key its stored copies on that header.
Pitfalls with CDNs and proxies
- Missing Vary. The most common bug. Add it whenever you compress dynamically or choose between pre-compressed variants.
- Vary fragmentation. Raw
Accept-Encodingvalues differ by browser, for examplegzip, deflate, bragainstgzip, deflate, br, zstd. If a cache keys on the raw string, one URL fragments into many entries and the hit rate drops. Most CDNs normalise the header into a small set of values, and you should check that yours does. - Origin compressed, edge decompressed. Some CDNs decompress at the edge and recompress with their own settings. That can undo your carefully chosen pre-compression, or give you gzip when you expected Brotli. Read your provider's documentation for how it treats
Content-Encodingfrom the origin. - Double compression. If the origin and a proxy both compress, you get a body encoded twice or a duplicate
Content-Encodingheader. Compress in exactly one place per path, and let the other layers pass bytes through. - ETags. A strong
ETagidentifies exact bytes, so the compressed and uncompressed versions must have different ones, and many servers convert them to weak validators or append a suffix. If conditional requests mysteriously fail to return304, check how your server handlesETagon compressed variants. - Range requests. A byte range of a compressed response refers to the compressed bytes. Servers often disable compression for range requests, or serve ranges from the identity version. Large media files are normally not compressed for this reason, as discussed below.
Cache-Control interplay
Compression does not change freshness rules. Hashed assets can still carry Cache-Control: public, max-age=31536000, immutable, and each encoding variant is cached separately under the Vary rule. When you deploy a new build, the new filenames mean new cache entries for both encodings.
Which content to compress, and which to leave alone
Compression pays off when the data has redundancy and the receiver needs the whole file. Text is the classic case.
Compress these
| MIME type | Typical content |
|---|---|
text/html |
Pages |
text/css |
Stylesheets |
text/javascript, application/javascript |
Scripts |
application/json |
API responses |
application/xml, text/xml |
Feeds, sitemaps, SOAP |
image/svg+xml |
Vector graphics, which are XML text |
text/plain |
Logs, text exports |
application/manifest+json, application/ld+json |
Web manifests, structured data |
application/wasm |
WebAssembly modules, which usually compress well |
image/x-icon, image/vnd.microsoft.icon |
Favicons that are plain ICO files |
Many default server configurations compress HTML, CSS and JavaScript only. JSON, SVG and XML are frequently missing from the default list, so check explicitly. If your API responses have no Content-Encoding header, this omission is the first suspect.
Do not compress these
- JPEG, PNG, WebP, AVIF, GIF. These formats already use their own compression. A second pass finds little to remove and wastes CPU.
- Video and audio such as MP4, WebM, MP3 and AAC, for the same reason, and because range requests matter for seeking.
- WOFF and WOFF2 fonts. WOFF2 uses Brotli internally. Compressing it again gains nothing. Plain TTF and OTF fonts, on the other hand, do compress well.
- ZIP, GZ, BR, 7z, RAR and other archives.
- PDF, usually, since the content streams inside are generally compressed already, although results vary by document.
- Very small responses. Compression has a fixed overhead.
You can see why with a quick experiment. I compressed 20,000 random bytes, which stand in for already-compressed data, and an 11-byte JSON body:
| Input | Original bytes | Gzip bytes | Brotli bytes |
|---|---|---|---|
| 20,000 random bytes | 20,000 | 20,028 | 20,004 |
{"ok":true} |
11 | 31 | 15 |
Random data grew slightly, and the tiny body grew by almost three times with gzip, because the header and trailer cost more than the savings. Gzipping an already-gzipped JSON file (8,508 bytes) gave 8,049 bytes, a gain of about five percent for a full second compression pass. That is not a gain worth the CPU, and it can also confuse clients if an extra Content-Encoding is declared.
Servers usually let you set a minimum size, commonly in the range of several hundred bytes to about one kilobyte, below which they skip compression. Leave that guard in place.
Server configuration examples
These are configuration forms to adapt, not copy blindly. The Node.js server in the verification section was run in the sandbox. The sandbox had no nginx or Apache installed, so the web-server snippets below are written from their documented directives and have not been executed here. Test them in a staging environment before production.
nginx
nginx ships with gzip support in its standard modules. Brotli is not built in; it requires a third-party module, commonly named ngx_brotli, or a distribution package that includes it.
# Dynamic gzip
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types
text/plain text/css text/xml
application/json application/javascript text/javascript
application/xml image/svg+xml application/wasm;
# Serve pre-compressed .gz files if present next to the original
gzip_static on;
# Brotli, only if the module is installed
brotli on;
brotli_comp_level 5;
brotli_min_length 1024;
brotli_types
text/plain text/css text/xml
application/json application/javascript text/javascript
application/xml image/svg+xml application/wasm;
brotli_static on;
Notes: text/html is always compressed once gzip on is set, so it does not need listing. gzip_vary on adds the Vary header for you. gzip_proxied any allows compression of responses to requests that came through a proxy, which matters behind load balancers. The *_static directives look for file.gz or file.br and serve it when the client accepts that encoding.
Apache
Apache uses mod_deflate for gzip, and mod_brotli for Brotli where it is available. Because the two modules can both apply, define which one you prefer and confirm in testing.
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/css text/xml
AddOutputFilterByType DEFLATE application/json application/javascript text/javascript
AddOutputFilterByType DEFLATE application/xml image/svg+xml application/wasm
Header append Vary Accept-Encoding
</IfModule>
<IfModule mod_brotli.c>
AddOutputFilterByType BROTLI_COMPRESS text/html text/plain text/css text/xml
AddOutputFilterByType BROTLI_COMPRESS application/json application/javascript text/javascript
AddOutputFilterByType BROTLI_COMPRESS application/xml image/svg+xml application/wasm
BrotliCompressionQuality 5
</IfModule>
Serving pre-compressed files in Apache usually involves mod_rewrite rules that check for a .br or .gz file and set Content-Encoding and the correct Content-Type. Those rules are easy to get subtly wrong, so lean on your platform's documentation or a build tool that handles it.
Node.js and Express
In Express, the compression middleware handles gzip and deflate based on Accept-Encoding. Whether it handles Brotli depends on the version you install, so read its README before assuming. This snippet follows its documented options and was not executed here, since no Express install was available in the sandbox.
const express = require("express");
const compression = require("compression");
const app = express();
app.use(
compression({
threshold: 1024, // skip tiny responses
level: 6, // zlib level for gzip
})
);
app.use(express.static("public", { maxAge: "1y", immutable: true }));
app.listen(3000);
For full control, here is a server using only Node's built-in modules. It parses Accept-Encoding including quality values, prefers Brotli, then gzip, falls back to plain bytes, and always sets Vary. I ran this exact code.
const http = require("http");
const zlib = require("zlib");
const body = Buffer.from(
JSON.stringify(
Array.from({ length: 300 }, (_, i) => ({ id: i, name: "Item " + i, tags: ["a", "b"] }))
)
);
function pick(header = "") {
const weights = {};
for (const part of header.split(",")) {
const [name, ...params] = part.trim().split(";");
let q = 1;
for (const p of params) {
const t = p.trim();
if (t.startsWith("q=")) q = parseFloat(t.slice(2));
}
if (name) weights[name.toLowerCase()] = q;
}
const ok = (e) => (weights[e] ?? weights["*"] ?? 0) > 0;
if (ok("br")) return "br";
if (ok("gzip")) return "gzip";
return null;
}
http
.createServer((req, res) => {
const enc = pick(req.headers["accept-encoding"]);
res.setHeader("Content-Type", "application/json");
res.setHeader("Vary", "Accept-Encoding");
if (enc === "br") {
const out = zlib.brotliCompressSync(body);
res.setHeader("Content-Encoding", "br");
res.setHeader("Content-Length", out.length);
return res.end(out);
}
if (enc === "gzip") {
const out = zlib.gzipSync(body);
res.setHeader("Content-Encoding", "gzip");
res.setHeader("Content-Length", out.length);
return res.end(out);
}
res.setHeader("Content-Length", body.length);
res.end(body);
})
.listen(8787);
The synchronous compression calls keep the example short. In a real server, use the asynchronous or streaming APIs, so a slow compression job does not block the event loop for other requests. Cache the compressed bytes for responses that repeat.
Verifying with curl
Never trust a config file; ask the server. With the Node server above running, I sent four requests with different Accept-Encoding values and printed the relevant headers:
for ae in "br, gzip" "gzip" "identity" "gzip;q=0, br;q=0"; do
echo "== $ae"
curl -s -o /dev/null -D - -H "Accept-Encoding: $ae" http://localhost:8787/ \
| grep -iE "content-encoding|content-length|vary"
done
The results were:
| Request header | Content-Encoding | Content-Length | Vary |
|---|---|---|---|
br, gzip |
br |
695 | Accept-Encoding |
gzip |
gzip |
1,496 | Accept-Encoding |
identity |
none | 13,581 | Accept-Encoding |
gzip;q=0, br;q=0 |
none | 13,581 | Accept-Encoding |
The 13,581-byte JSON became 1,496 bytes with gzip and 695 bytes with Brotli. This payload is highly repetitive, so those ratios are far better than you will see on real data. What matters is the behaviour: the server honours the preference, falls back correctly, and honours q=0 as a refusal.
Useful curl recipes
curl -sI -H "Accept-Encoding: br" https://example.com/app.jsfetches headers only. Some servers answerHEADdifferently fromGET, so confirm with a full request too.curl -s -o /dev/null -D - -H "Accept-Encoding: gzip" URLprints headers for a realGETand discards the body.curl --compressed -s -o /dev/null -w "%{size_download}\n" URLreports the bytes that actually crossed the wire. The flag tellscurlto sendAccept-Encodingand decode the response.curl -s -H "Accept-Encoding: gzip" URL | gunzip | headproves that the body really is gzip, becausegunzipfails on anything else.- For Brotli, pipe through a Brotli decoder if you have one installed. If you do not, the response headers and the
size_downloadvalue still confirm the encoding.
Remember that the browser's network panel shows two numbers: the transferred size and the resource size. The gap between them is the compression you are actually getting, including any CDN behaviour in the middle.
Step by step: measure gzip with DevUtilX
Before you change a server, find out what compression would buy you on your own files. The DevUtilX Gzip Compressor runs in your browser. Your input is processed locally and is not uploaded, so it is safe for internal bundles and private payloads.
- Open the Gzip Compressor tool.
- Paste the text you want to measure: a stylesheet, a script, a JSON response or a page of HTML.
- Run the compression.
- Compare the original size with the compressed size shown by the tool.
- Copy the result, or note the ratio, and compare it with what your server sends.
Use the numbers as a sanity check. If the tool reports a large saving and your live response is not compressed, the server is missing a configuration step. If the saving is tiny, the content is already dense, and better results will come from trimming the data itself. Our guides to JSON minification and payload size and JavaScript minification cover that next step.
Remember the tool measures gzip. Brotli at its best settings will usually produce a smaller file, as the tables above show, so treat the gzip figure as a conservative baseline.
Code examples in three languages
The examples below were run in the sandbox. They cover compressing, decompressing and measuring in Node.js, Python and the shell.
Node.js: gzip and Brotli side by side
const zlib = require("zlib");
const text = Buffer.from(JSON.stringify({ hello: "world", list: Array(100).fill("repeat") }));
const gz = zlib.gzipSync(text, { level: 9 });
const br = zlib.brotliCompressSync(text, {
params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 },
});
console.log("original", text.length);
console.log("gzip ", gz.length, zlib.gunzipSync(gz).equals(text));
console.log("brotli ", br.length, zlib.brotliDecompressSync(br).equals(text));
The equals calls prove a lossless round trip. Always include a round-trip check in tests that touch compression.
Python: gzip with the standard library
Python's standard library handles gzip. It does not include Brotli, and the brotli package was not installed in the sandbox, so I measured Brotli from Node.js instead of inventing Python numbers.
import gzip
import json
items = [
{"id": i + 1, "name": f"Product {i + 1}", "inStock": i % 3 != 0, "tags": ["new", "sale"]}
for i in range(500)
]
raw = json.dumps(items, separators=(",", ":")).encode()
print("raw", len(raw))
for level in (1, 6, 9):
print("gzip level", level, len(gzip.compress(raw, compresslevel=level)))
On this sample the output was 34,452 raw bytes, then 3,119 at level 1, 2,882 at level 6 and 2,867 at level 9. Notice how little level 9 gained over level 6 here, about half a percent. The plateau arrives earlier on highly regular data, which is another reason to measure instead of assuming that "higher is better".
Shell: compress, inspect, decompress
# Compress, keeping the original
gzip -k -9 app.css
# Show original and compressed sizes
gzip -l app.css.gz
# Decompress to stdout and compare
gzip -dc app.css.gz | cmp - app.css && echo "identical"
The gzip -l command prints the compressed size, the uncompressed size and the ratio. If you have the brotli command line tool installed, brotli -q 11 -k app.css produces app.css.br in the same manner.
Security, performance and accessibility considerations
Compression and secrets
Compression leaks information through size. If a response combines a secret, such as a CSRF token, with text an attacker can influence, such as a reflected query parameter, then the compressed length changes depending on whether the attacker's guess matches the secret. Attacks in the BREACH family exploit this over HTTPS, observing lengths only. Mitigations include masking or randomising secrets per response, separating secrets from reflected input, adding rate limits, and disabling compression for sensitive endpoints. This is a narrow situation, but it is the reason compression is a conscious security decision and not purely an optimisation.
Decompression bombs
On the receiving side, a small compressed upload can expand to a huge amount of memory. If your server accepts Content-Encoding: gzip on request bodies, or decompresses files that users upload, cap the decompressed size and abort when the limit is exceeded. Never decompress untrusted input into an unbounded buffer.
CPU, memory and latency
Dynamic compression costs CPU on every request and memory for the compression state. Under heavy traffic, high levels can raise latency for everyone. Choose moderate settings, cache compressed bytes for responses that repeat, and monitor CPU after enabling compression on a busy service. Offload to a CDN or reverse proxy when you can, since those layers are tuned for it.
Compression also interacts with streaming. A compressor buffers some input before it emits output, so a server-sent event stream or a progressively rendered page can appear to stall unless the server flushes the compressor after each message. If a streaming endpoint seems to deliver in bursts after you enable compression, this is a likely cause.
Time to first byte against total transfer
Compression reduces the time spent transferring bytes, but a slow compressor adds to the time before the first byte. For a large page on a fast connection, an expensive compression step can make the page slower overall. For a small page on a slow mobile connection, the byte savings dominate. This is the reason low levels are the safe default for dynamic content, and why pre-compressing static files at the highest level makes sense.
Accessibility and user experience
Compression has no direct effect on markup or accessibility semantics, since the browser decodes the body before parsing. The indirect effect is real. Faster delivery helps people on slow connections, older devices and metered data plans, who are disproportionately likely to depend on the web for essential services. Smaller transfers also help assistive technology users whose pages fail or time out less often. A page that never finishes loading is an accessibility failure for anyone relying on it.
Compression ahead of a CDN and image formats
Do not use compression as a substitute for choosing the right format. A photograph served as an uncompressed bitmap will not be rescued by gzip. Use a proper image codec for images, and keep Content-Encoding for text.
Common mistakes and pitfalls
- Compressing only HTML, CSS and JavaScript. JSON, SVG and XML need explicit entries in many configurations.
- Forgetting
Vary: Accept-Encoding. Caches then serve the wrong variant to some clients. - Using Brotli quality 11 on the fly. It is far too slow for request time. Reserve it for static builds.
- Hard-coding Brotli. Clients that do not send
brwill fail to decode. Always negotiate. - Assuming plain HTTP gets Brotli. Browsers request it on HTTPS only.
- Compressing images, video and fonts that are already compressed. It wastes CPU and can increase size.
- Double compression. An application and a proxy both compress, producing a body that cannot be decoded.
- Setting
Content-Lengthbefore compressing. The header must describe the compressed bytes, or be omitted in favour of chunked transfer. A mismatch truncates or hangs responses. - Testing only in the browser. Browsers cache aggressively and hide headers. Test with
curland a clean cache. - Compressing responses with secrets and reflected input. This opens the door to BREACH-style attacks.
- Skipping the size threshold. Tiny bodies can grow.
- Not measuring. The best setting depends on your data, traffic pattern and hardware.
Troubleshooting: symptom, cause, fix
Symptom: the response has no Content-Encoding header
Cause. Compression is off, the MIME type is not in the compressible list, the body is under the minimum size, or the client did not send Accept-Encoding.
Fix. Confirm the request header with curl -H "Accept-Encoding: gzip". Then add the content type to the server's list and check the size threshold.
Symptom: the browser shows garbled text or ERR_CONTENT_DECODING_FAILED
Cause. The body does not match the declared encoding. Typical reasons are double compression, a stale cached variant served without Vary, or a Content-Encoding: gzip header on bytes that are not gzip.
Fix. Compress in only one layer. Add Vary: Accept-Encoding. Purge caches. Pipe the raw response through gunzip to see whether it really is gzip.
Symptom: Brotli never appears
Cause. The connection is plain HTTP, the server lacks the Brotli module, the client did not advertise br, or the CDN strips or rewrites the encoding.
Fix. Test over HTTPS. Verify the module is loaded. Request with -H "Accept-Encoding: br". Check your CDN's compression settings.
Symptom: the page hangs or truncates after enabling compression
Cause. A Content-Length computed before compression, a proxy buffering a streaming response, or an application that closes the stream without finishing the compressor.
Fix. Remove the manual Content-Length and let the framework set it, or compute it from the compressed bytes. Flush streaming compressors. End the compression stream properly.
Symptom: CPU is high after enabling compression
Cause. The level is too high for per-request work, large responses are recompressed repeatedly, or already-compressed files are being processed.
Fix. Lower the level, such as gzip around 5 or 6 and Brotli around 4 to 5. Cache compressed output, pre-compress static files, and exclude binary types.
Symptom: the cache hit rate dropped
Cause. The CDN keys on the raw Accept-Encoding string, fragmenting each URL into many variants.
Fix. Enable normalisation of Accept-Encoding in the CDN, or configure it to vary only on the encodings you serve.
Symptom: conditional requests no longer return 304
Cause. The server changes or drops the ETag for compressed responses, or the validator differs between the identity and compressed variants.
Fix. Use consistent weak validators across variants, and check how your server rewrites ETag on compression.
Symptom: a streaming or server-sent events endpoint arrives in bursts
Cause. The compressor buffers data until it has enough to emit.
Fix. Flush after each message, or exclude the streaming endpoint from compression.
Symptom: the compressed file is larger than the original
Cause. The input is already compressed, random, or tiny.
Fix. Exclude that type or size from compression.
Best practices and checklist
- Enable compression for all text responses, including JSON, SVG and XML.
- Offer Brotli and gzip. Serve Brotli to clients that ask, gzip to the rest, and plain bytes if neither is accepted.
- Pre-compress static assets at gzip 9 and Brotli 11 during the build.
- Use moderate settings for dynamic content, such as gzip 5 to 6 and Brotli 4 to 5, and measure CPU.
- Always send
Vary: Accept-Encodingwhere the response can differ by encoding. - Compress in one layer and let the others pass bytes through.
- Skip already-compressed formats, tiny responses and range-heavy media.
- Minify first, then compress. See our guides to CSS minification and HTML minification.
- Verify with curl after every change, and compare the transferred size with the resource size in the browser.
- Think about secrets. Decide deliberately whether sensitive responses should be compressed.
- Set size budgets in CI, measured on compressed output, because that is what users download.
- Keep test cases for negotiation, including
q=0, a missing header and the wildcard.
A short pre-launch checklist:
- Does
curl -H "Accept-Encoding: br, gzip"returnContent-Encodingon your HTML, CSS, JavaScript and JSON? - Is
Vary: Accept-Encodingpresent on those responses? - Does a request with
Accept-Encoding: identityget plain bytes? - Are images, video and fonts left alone?
- Are static assets pre-compressed, and is the server choosing the files correctly?
- Is CPU stable under load with the chosen levels?
- Does the CDN preserve or normalise encodings the way you expect?
- Are sensitive endpoints reviewed for BREACH-style risks?
Comparison: compression and related options
| Option | Typical effect on text | Speed to encode | Compatibility | Notes |
|---|---|---|---|---|
| No compression | None | None | Universal | Wasteful for text |
| gzip (DEFLATE) | Large | Fast | Universal | Safe default, 32 KB window |
| Brotli | Larger than gzip, often noticeably | Fast at low quality, very slow at 11 | Browsers over HTTPS | Static dictionary, best for pre-compression |
| deflate token | Similar to gzip | Fast | Inconsistent history | Rarely worth choosing over gzip |
| zstd | Strong ratio and speed in many cases | Fast | Newer, support still growing | Check client and CDN support before relying on it |
| Minification only | Small, format-dependent | Build time | Universal | Complements compression, does not replace it |
| Smaller data design | Often the largest | Design effort | Universal | Remove fields, paginate, cache |
| HTTP caching | Eliminates repeat transfers | None | Universal | Best byte is the one never sent |
The deflate row deserves a note. The deflate content coding is supposed to be the zlib wrapper around DEFLATE data, but some implementations historically sent raw DEFLATE, which caused interoperability problems. Gzip avoids the ambiguity, which is why most servers offer gzip and skip deflate.
FAQ
Is Brotli always better than gzip?
Not always. Brotli usually produces smaller text files, but its highest settings are very slow, and it is only advertised by browsers over HTTPS. For dynamic responses, a moderate Brotli quality is often a good choice, and gzip remains a perfectly reasonable fallback.
Which Brotli quality should I use?
For responses generated per request, try qualities around 4 to 6 and measure your CPU. For files compressed once at build time, quality 11 is common, since its slowness only affects your build. Measure on your own data, because results vary.
Does Brotli work over HTTP?
Browsers do not send br in Accept-Encoding on plain HTTP connections, so in practice Brotli requires HTTPS for browser traffic. Other clients such as curl can request it over HTTP if the server supports it.
Do I still need gzip if I enable Brotli?
Yes. Some clients, tools and older systems send only gzip, and plain HTTP traffic from browsers uses gzip. Keep both, and let negotiation pick.
What does Vary: Accept-Encoding do?
It tells caches that the response differs depending on the request's Accept-Encoding header, so they store and serve each encoding separately. Without it, a cache can send the wrong encoding to a client.
Should I compress images?
No, not with HTTP compression. JPEG, PNG, WebP, AVIF and GIF are already compressed. Choose a better image format or quality setting instead. SVG is the exception, since it is text and compresses well.
Why is my JSON API not compressed even though HTML is?
Many default configurations list only a few MIME types. Add application/json to the compressible types and re-test with curl.
Is it safe to compress HTTPS responses?
Yes in general, but compressing responses that mix secrets with attacker-controlled text can leak information through response size, as in BREACH. Mask secrets, separate them from reflected input, or skip compression on those endpoints.
What is the difference between gzip and deflate in HTTP?
Gzip is DEFLATE data with a header and checksum. The deflate coding is meant to be a zlib wrapper, and implementations have not always agreed on the format. Prefer gzip.
Can I serve pre-compressed files?
Yes, and it is the best option for static assets. Create .gz and .br copies at build time, and configure the server to send them with the correct Content-Encoding, Content-Type and Vary headers.
Does compression change my file?
No. Compression is lossless, and the browser restores the exact original bytes. That is why you should keep a round-trip test in your pipeline.
Is Gzip Compressor safe for private files?
The DevUtilX tool runs in your browser and does not upload your input. As with any tool, avoid pasting secrets, tokens or personal data unless you need to.
Will minifying still help if I compress?
Yes, modestly. Minification removes work the compressor would otherwise do, and it reduces parse time. It is not a substitute for compression, and compression is not a substitute for it.
How do I know what the client actually received?
Use the transferred size in the browser's network panel, or curl --compressed -w "%{size_download}". Both report bytes on the wire, not the decoded size.
Conclusion
Compression is the cheapest performance win on the web, and the details decide whether it works. Negotiate with Accept-Encoding and Content-Encoding, send Vary: Accept-Encoding, compress text and leave already-compressed files alone, pre-compress static assets with Brotli at its top quality and gzip as a fallback, and keep dynamic settings moderate. Then verify with curl instead of trusting configuration. To check what gzip would save on your own files, try the Gzip Compressor, which runs entirely in your browser. For more on shrinking what you send, read our guides on JSON minification and payload size and JavaScript minification.

.jpg)
.jpg)