gzip, Brotli, and Zstandard
Compare the compression formats, pick levels, and compress at build time or on the fly. A React bundle compressed at every level of gzip, Brotli and Zstandard, then loaded in Chrome on a slow link and a fast one, from Node and from nginx: what each level costs, when compressing on the fly loses, what not to compress, and how to check what arrived.
- 01Pick a format and a levelSend Brotli or Zstandard when the browser lists them, gzip otherwise, at a level that fits when you compress.
- 02Compress at the right momentUse the highest level once, at build time. Use a middle level for responses made per request.
- 03Check what arrivedRead Content-Encoding, compare encodedBodySize with decodedBodySize, and check your server's defaults.
Smaller on the wire
Asha opens the Mint tea shop's spring sale page on her phone. Her connection is slow. She looks at a blank screen, and nothing appears for 1,760 ms, almost two seconds. A millisecond (ms) is a thousandth of a second.
The page is a React app. Its HTML holds just an empty box. A JavaScript file, the bundle, draws everything inside it: the heading and the sale's text. Until the bundle has arrived and run, there's nothing to see.
The course built that bundle from React 19.3.1, React DOM and the shop's own code. Then it minified it with terser, which strips out the comments and spaces a browser doesn't need. The bundle is still 219.1 kB. A kB, a kilobyte, is 1,000 bytes, the way DevTools counts. On Asha's slow link, the bundle was on its way for 1,252 ms of those 1,760.
So here's the question. Can the shop show its page sooner without changing a single line of its code? It can, by sending the same bundle in fewer bytes. The course tried both ways in Chrome on a slow link, like DevTools' Slow 4G preset (see Choosing test conditions). Each way ran 5 times, with a new browser each time. Here are the medians, the middle result of each five:
// Asha opens the sale page on a slow link: the median of 5 visits each way.// Sizes are in bytes. Times are in milliseconds (ms) after the visit started.const sentAsItIs = { bundle: 219093, textOnScreen: 1760 };const compressed = { bundle: 58282, textOnScreen: 956 }; console.log(`Sent as it is: ${sentAsItIs.bundle} bytes, text on screen at ${sentAsItIs.textOnScreen} ms.`);console.log(`Compressed: ${compressed.bundle} bytes, text on screen at ${compressed.textOnScreen} ms.`);- Lines 1 and 2 are comments. They say what was measured, and in what units.
- Line 3 is the bundle sent as it is: 219,093 bytes.
- Line 4 is the same bundle after the server compressed it with Brotli, one of the formats in this lesson: 58,282 bytes.
- "Text on screen" is the page's LCP, Largest Contentful Paint: the moment the biggest text or image appeared. Here, that's the sale's text.
- Lines 6 and 7 print
Sent as it is: 219093 bytes, text on screen at 1760 ms.andCompressed: 58282 bytes, text on screen at 956 ms.
The text appeared 804 ms sooner. The code was the same, and so was the server. Only the bytes on the wire changed: about a quarter as many crossed the link.
That's compression: writing data in a shorter form that can be turned back into exactly the same data. The server compresses the bundle before it sends it. The browser decompresses it, turning it back, as it arrives. Here is the whole trip:
You're flying to see your family with one small suitcase. You roll every shirt tight and squeeze the air out of the bag, so far more fits. At the other end you unpack, and every shirt comes out just as it went in.
- In real life: Rolling the shirts and squeezing out the air
- In HTTP: The server compressing the bundle
- In real life: The small, full suitcase on the plane
- In HTTP: The 58,282 bytes on the wire
- In real life: Unpacking at the other end
- In HTTP: The browser decompressing them
- In real life: The same shirts, ready to wear
- In HTTP: The same 219,093 bytes of code
It works on any file made of text: HTML, CSS, JavaScript, JSON or SVG. web.dev says gzip and Brotli "perform best on text-based assets". Photos don't shrink this way. The section on what not to compress shows why.
Compress text before it travels, in a format the browser says it can decompress. The browser gets back exactly the same file. Compress files that are the same for everyone once, as hard as you can, at build time. Compress responses made for each request quickly, at a middle level. Skip files that are already compressed. Then check what really arrived.
Compression and minification, in the system design course, shows where these steps fit in a build.
Where the savings come from
How can 219,093 bytes of code fit into 58,282? Because text repeats itself. MDN says text "can typically have as much as 60% redundancy": bytes that say again what was already said. A compressor hunts for those repeats.
You're copying out a recipe for a friend. Step 5 says exactly what step 3 said. So you don't write it out again. You write "same as step 3". Your friend looks back and reads the same words. The note is shorter, and nothing is lost.
- In real life: The steps you've already written
- In a compressor: The text the browser has already decompressed
- In real life: "Same as step 3"
- In a compressor: A pointer: how far back to look, and how much to copy
- In real life: Your friend looking back at step 3
- In a compressor: The browser copying text it already has
- In real life: The same recipe, in fewer words
- In a compressor: The same file, in fewer bytes
Compressors do exactly this. When some text has appeared before, they write a short pointer instead: how far back to look, and how many characters to copy. gzip uses a method called DEFLATE. Its spec, RFC 1951, calls a pointer "a pair <length, backward distance>". Here is a browser following one:
// The browser has decompressed this much so far.let text = "Mint tea, green ";// Next in the file comes a pointer: go back 11 characters and copy 3.const pointer = { back: 11, copy: 3 }; const start = text.length - pointer.back;const copied = text.slice(start, start + pointer.copy);text += copied;console.log(`The pointer copied "${copied}".`);console.log(`The text is now "${text}".`);- Line 2 holds the text decompressed so far:
Mint tea, green, 16 characters. - Line 4 is the pointer that comes next in the file: go back 11 characters, and copy 3.
- Line 6 works out where to start. 16 minus 11 is 5, the place where the first
teabegins. - Line 7 copies 3 characters from there. Line 8 adds them to the end.
- Lines 9 and 10 print
The pointer copied "tea".andThe text is now "Mint tea, green tea".
A pointer is only worth writing when the repeat is long enough. In DEFLATE, one pointer copies at least 3 bytes and at most 258. After the pointers, a second step writes common symbols in fewer bits than rare ones.
Notice how little the browser has to do: it just copies. The hard part, finding the repeats, is the server's. Brotli took up to 272.7 ms to compress the bundle, and under a millisecond to decompress it.
Why it matters: the more a file repeats itself, the more it shrinks. Text repeats a lot, so text shrinks a lot.
How gzip, Brotli, and Zstandard compare
A compression format is a set of rules for writing the pointers and everything else. A browser can only decompress a format it knows. The web uses three:
- gzip is the oldest. Its spec, RFC 1952, dates from 1996. Its pointers reach back at most 32K bytes. Chrome's docs say gzip "is supported in all major browsers".
- Brotli came from Google in 2016, in RFC 7932. Its pointers can reach back about 500 times as far as gzip's. It also has a built-in dictionary: a list of words, 4 to 24 bytes long, that every Brotli decoder already knows. A pointer can point into it, so even a word's first use can be short.
- Zstandard, or zstd, came from Facebook. RFC 8878, from 2021, calls it "a data compression mechanism, akin to gzip". It also works with pointers back.
How far back a pointer may reach is called the window. RFC 8878 says larger windows "tend to improve the compression ratio, but at the cost of increased memory usage." For HTTP, RFC 9659 puts a limit on Zstandard's window, so a browser never needs too much memory.
Each format can also work harder or lighter, at different levels. The next section explains them. Here is the smallest each format made the bundle, at any level:
// The bundle at the level where each format made it smallest. Sizes in bytes.const bundle = 219093;const smallest = { "gzip 9": 68219, "Brotli 11": 58282, "Zstandard 19": 61491,}; for (const [format, size] of Object.entries(smallest)) { const saved = Math.round((1 - size / bundle) * 100); console.log(`${format}: ${(size / 1000).toFixed(1)} kB, ${saved}% smaller`);}- Line 2 is the bundle's size as built, in bytes.
- Lines 4 to 6 are the smallest each format made it. The number after each name is the level that did it.
- Line 10 works out how much smaller each one is, in percent. Line 11 prints it, with the size in kB.
- It prints
gzip 9: 68.2 kB, 69% smaller,Brotli 11: 58.3 kB, 73% smallerandZstandard 19: 61.5 kB, 72% smaller.
All three cut the bundle by more than two thirds. Brotli made the smallest file, about 10 kB smaller than gzip's. Zstandard came in between. Why it matters: with the same code, the format alone changes how much Asha downloads.
Agreeing on a format
The server can't just pick its favourite. If it sent Brotli to a browser that can't decompress Brotli, the browser couldn't read the file. So on every request, the two agree first.
At a café abroad, you tell the waiter which languages you can read. The café has menus in several. The waiter picks one from your list, hands it over, and says which language it's in. You never get a menu you can't read.
- In real life: The languages you say you can read
- In HTTP:
Accept-Encoding: the formats the browser can decompress - In real life: The menus the café has
- In HTTP: The formats the server can send
- In real life: The waiter saying which language it's in
- In HTTP:
Content-Encoding: the format the server used
In HTTP, the browser's list is a request header, Accept-Encoding. RFC 9110 says it "indicates the content codings acceptable in a response". The server's answer is a response header, Content-Encoding. It names the format the server used, so the browser knows how to decompress the body. Here is what Chrome sent for the bundle, and what came back:
Chrome's list has four names. Three are the formats in this lesson. The fourth, deflate, is DEFLATE in a different wrapper from gzip's. Here is one way a server could choose. It goes down its own list, favourite first, and takes the first format the browser named:
// What Chrome sent over HTTPS, and what this server can send, its favourite first.const acceptEncoding = "gzip, deflate, br, zstd";const serverCanSend = ["br", "zstd", "gzip"]; const browserCanRead = acceptEncoding.split(", ");const chosen = serverCanSend.find((format) => browserCanRead.includes(format));console.log(`Content-Encoding: ${chosen}`);- Line 2 is the
Accept-Encodingheader Chrome sent over HTTPS. - Line 3 is the server's own list, its favourite first.
- Line 5 splits the header into its four names.
- Line 6 finds the first of the server's formats that the browser named.
- Line 7 prints
Content-Encoding: br.
Real servers each have their own rules, but the idea is the same: only send a format the browser listed. Over plain HTTP, without the S, Chrome listed fewer: gzip, deflate. No Brotli and no Zstandard. So in Chrome, a site without HTTPS gets gzip at best.
One more header goes with every compressed response: Vary. HTTP caching in depth pictured a cache as a fridge with one shelf for each address. Vary splits a shelf. MDN says a compressing server "must send a Vary header containing at least Accept-Encoding".
Here's why. Say a CDN, a shared cache, stores the Brotli copy of the bundle. Next, a browser that can only read gzip asks for it. With Vary: Accept-Encoding, RFC 9111 lets the CDN reuse a stored copy only when the new request's Accept-Encoding matches. Otherwise it has to ask the server. So nobody gets bytes they can't decompress.
Levels and CPU cost
Brotli made its smallest file at level 11. So why not always use level 11? Because a smaller file costs time to make.
A level sets how much effort the compressor spends. More effort squeezes out a little more, like one more push on a full suitcase. web.dev gives the ranges: "For gzip, compression settings range from 1 to 9, with 9 being the best. For Brotli, this range is 0 to 11, with 11 being the best. However, higher compression settings require more time."
That time is the server's CPU time: its processor doing the work. The course compressed the bundle at every level, 9 times each, with Node's built-in zlib module. It kept the median time. For Zstandard, it tried levels 1 to 22. Start with Brotli at a middle level and at its highest:
// The bundle compressed with Brotli at two levels: the size in bytes,// and the median time to compress it of 9 tries, in ms.const level5 = { size: 64434, ms: 3.8 };const level11 = { size: 58282, ms: 272.7 }; console.log(`Level 11 saved ${level5.size - level11.size} more bytes.`);console.log(`It took ${Math.round(level11.ms / level5.ms)} times as long.`);- Line 3 is level 5. The bundle came out at 64,434 bytes, in 3.8 ms.
- Line 4 is level 11: 58,282 bytes, in 272.7 ms.
- Line 6 subtracts the sizes. It prints
Level 11 saved 6152 more bytes. - Line 7 divides the times. It prints
It took 72 times as long.
About 6 kB of savings cost about 269 ms of extra work. The picture shows a few levels of each format. Each bar is drawn to scale:
Each format has a point where the time jumps. Read Brotli's last three bars:
- Level 9 took 7.7 ms.
- Level 10 took 103.4 ms.
- Level 11 took 272.7 ms.
The top levels also stop paying off. gzip 9 made the bundle no smaller than gzip 8 did. Past level 19, Zstandard made it no smaller either.
What about the browser's side? Decompressing the bundle took between 0.2 and 0.6 ms at every level, in Node on the same computer. Following pointers is quick, whatever level wrote them. So a high level costs whoever compresses, not whoever decompresses.
Now the middle levels of all three, side by side:
// Each format at a middle level: the size in bytes,// and the median time to compress the bundle of 9 tries, in ms.const middle = { "gzip 6": [68399, 4.1], "Brotli 5": [64434, 3.8], "Zstandard 3": [70299, 0.9],}; for (const [level, [size, ms]] of Object.entries(middle)) { console.log(`${level}: ${(size / 1000).toFixed(1)} kB in ${ms} ms`);}- Lines 4 to 6 hold each format's size, in bytes, and its time, in ms.
- Lines 9 to 11 print one line for each format.
- It prints
gzip 6: 68.4 kB in 4.1 ms,Brotli 5: 64.4 kB in 3.8 msandZstandard 3: 70.3 kB in 0.9 ms.
All three took a few milliseconds or less. Brotli 5 made the smallest file of the three, and Zstandard 3 was the quickest. Why it matters: when a server must compress while the visitor waits, the middle levels are cheap.
At build time or per request
When the server compresses a file matters as much as how hard. Some files are the same for every visitor, like the shop's bundle. Others are made fresh for each request, like a page that shows a visitor's own basket. So there are two moments to compress:
- At build time. Compress the file once, when you build the site, and save the result next to it. web.dev calls this static compression. It can "use the most aggressive compression settings available", because the work is done ahead of time.
- Per request. The server compresses each response as it sends it. web.dev calls this dynamic compression. It says settings "in the middle of the range" tend to suit it best.
Pack the night before, and you have time to roll every shirt and squeeze out every bit of air. Pack at the door with the taxi waiting, and you fold quickly and go. Rolling every shirt at the door would only make you miss the taxi.
- In real life: Packing the night before
- In HTTP: Compressing at build time
- In real life: Rolling every shirt tight
- In HTTP: The highest level, like Brotli 11
- In real life: Packing at the door, with the taxi waiting
- In HTTP: Compressing per request, while the visitor waits
- In real life: A quick fold
- In HTTP: A middle level, like gzip 6 or Brotli 5
First, Brotli 11 on the slow link. The bytes were the same both ways. Only the moment of compressing changed:
// Brotli 11 on the slow link: the same 58,282 bytes, compressed at two moments.// The median LCP of 5 runs, in ms.const perRequest = 1232;const atBuildTime = 956; console.log(`Compressed for each request: text on screen at ${perRequest} ms.`);console.log(`Compressed at build time: text on screen at ${atBuildTime} ms.`);console.log(`Compressing per request cost ${perRequest - atBuildTime} ms.`);- Line 3: compressed for each request, the text appeared at 1,232 ms.
- Line 4: compressed once at build time, it appeared at 956 ms.
- Lines 6 and 7 print
Compressed for each request: text on screen at 1232 ms.andCompressed at build time: text on screen at 956 ms. - Line 8 subtracts them. It prints
Compressing per request cost 276 ms.
The same bytes crossed the same link. So where did the 276 ms go? Into the server. Brotli 11 took about 280 ms to compress the bundle, for every request, and Asha waited the whole time: rolling every shirt with the taxi at the door.
On the fast link, compressing per request did worse than not compressing at all:
// The fast link. The median LCP of 5 runs, in ms.const sentAsItIs = 480;const brotli11PerRequest = 616; console.log(`Sent as it is: text on screen at ${sentAsItIs} ms.`);console.log(`Brotli 11, per request: text on screen at ${brotli11PerRequest} ms.`);- Line 2 is the bundle sent as it is. Line 3 is Brotli 11, compressed per request.
- Lines 5 and 6 print
Sent as it is: text on screen at 480 ms.andBrotli 11, per request: text on screen at 616 ms.
On the fast link, the whole 219.1 kB bundle arrived about 260 ms after Chrome asked for it. Brotli 11 needed about 280 ms just to compress it. The work took longer than the trip it was meant to shorten. Why it matters: while a visitor waits, use a middle level, as web.dev advises.
A middle level is the quick fold. Here are two on the slow link, against sending the bundle as it is:
// The slow link. The median LCP of 5 runs, in ms.const sentAsItIs = 1760;const perRequest = { "gzip 6": 1000, "Brotli 5": 988 }; for (const [level, lcp] of Object.entries(perRequest)) { console.log(`${level}, per request: ${sentAsItIs - lcp} ms sooner`);}- Line 2 is the bundle sent as it is: text on screen at 1,760 ms.
- Line 3 holds gzip 6 and Brotli 5, each compressed per request.
- Lines 5 to 7 print how much sooner each one was:
gzip 6, per request: 760 ms soonerandBrotli 5, per request: 772 ms sooner.
gzip 6 cost the server about 5 ms per request. On the fast link it still made the text appear 132 ms sooner. Here are four of the setups on both links:
Brotli 11 compressed at build time was the fastest of the four on both links. It sent the fewest bytes, and spent no time compressing while Asha waited.
The build step
Here is the build step the course ran. It writes two compressed copies next to the bundle:
import { readFileSync, writeFileSync } from "node:fs";import { brotliCompressSync, constants, gzipSync } from "node:zlib"; const file = "dist/app.4f2b.js";const code = readFileSync(file);writeFileSync(`${file}.br`, brotliCompressSync(code, { params: { [constants.BROTLI_PARAM_QUALITY]: 11 } }));writeFileSync(`${file}.gz`, gzipSync(code, { level: 9 }));- Lines 1 and 2 load Node's file tools and its compression module,
zlib. - Line 4 names the bundle's file. Line 5 reads it.
- Line 6 writes a Brotli 11 copy,
app.4f2b.js.br, for browsers that ask forbr. - Line 7 writes a gzip 9 copy,
app.4f2b.js.gz, for the rest.
Then, for each request, the server picks the copy the browser can read. The course's Node server sent the .br copy with Content-Encoding: br. nginx can do the same for gzip. Its gzip_static module "allows sending precompressed files with the “.gz” filename extension instead of regular files." A CDN can do it for you: web.dev notes that CDNs "can also manage dynamic and static compression for you". See CDNs and the edge.
Why it matters: the bundle's name holds a hash, 4f2b, so its content never changes under that name. A caching strategy gave such files a new name whenever they change. Compress each one once, as hard as you can, and every visitor gets the smallest file at no cost per request.
What not to compress
If compressing helps this much, why not compress every file? The course tried it on files where it shouldn't help.
A parcel arrives vacuum-sealed: flat, hard, with no air left inside. You try to squeeze it smaller. Nothing gives. Then you stick a new label on it, so now it's a little bigger than before.
- In real life: The vacuum-sealed parcel
- In HTTP: A JPEG photo, already compressed
- In real life: Squeezing it again
- In HTTP: Running gzip or Brotli on it
- In real life: The new label
- In HTTP: The extra bytes a format adds around the data
A JPEG is already compressed. MDN lists JPEG among the lossy formats: ones that change the data in ways you shouldn't notice, to save space. Web video is lossy too. MDN recommends compression "for all files except already compressed ones like images, audio files and videos". Here is the shop's hero photo, compressed again:
// The shop's hero photo, a JPEG, compressed again with gzip 6. Sizes in bytes.const photo = { size: 102809, gzip: 99463 }; const saved = photo.size - photo.gzip;console.log(`gzip saved ${saved} bytes, ${Math.round((saved / photo.size) * 100)}% of the photo.`);- Line 2 holds the photo's size, and its size after gzip 6, in bytes.
- Line 4 subtracts them. Line 5 prints
gzip saved 3346 bytes, 3% of the photo.
That's about 3%, and the server works for it on every request. The photo already got its savings when it was saved as a JPEG.
Compressing a file twice is worse. MDN warns that it "usually provides nothing", and that it "is often counterproductive". The course compressed the build step's gzip copy of the bundle again. gzip made it 43 bytes bigger, and Brotli 5 bytes bigger.
Tiny files grow too. Here is a 16-byte reply from the shop's stock check:
// A tiny JSON reply, {"inStock":true}, compressed two ways. Sizes in bytes.const reply = { size: 16, gzip: 36, brotli: 20 }; console.log(`gzip: ${reply.size} bytes became ${reply.gzip}.`);console.log(`Brotli: ${reply.size} bytes became ${reply.brotli}.`);- Line 2 holds the reply's size, and its size after gzip 6 and after Brotli 5.
- Lines 4 and 5 print
gzip: 16 bytes became 36.andBrotli: 16 bytes became 20.
Why 36? RFC 1952 says every gzip file starts with a header of at least 10 bytes, and ends with 8 more: a check value and the original size. That's at least 18 bytes around the data. On a 16-byte reply, the label is bigger than the parcel.
Your server's defaults
The course also served the shop from nginx, a web server, with just one line: gzip on;. nginx compressed the page itself, but sent the 219.1 kB bundle as it is. You'll find it in the playground at the end of the next section, as nginx, gzip on.
Why? nginx only compresses the types you list. A file's type is its MIME type, the label in its Content-Type header, like text/html. nginx's docs for gzip_types say it "Enables gzipping of responses for the specified MIME types in addition to “text/html”." The default list is text/html alone. nginx sent the bundle as application/javascript, which wasn't on it. Set up like this, nginx sent the bundle compressed:
gzip on;gzip_comp_level 5;gzip_types text/css application/javascript image/svg+xml;gzip_vary on;gzip_static on;- Line 1 turns compression on.
- Line 2 sets the gzip level to 5, a middle level. nginx's default is 1.
- Line 3 lists more types to compress: CSS, JavaScript and SVG.
- Line 4 adds
Vary: Accept-Encoding, which nginx leaves out by default. - Line 5 sends the build step's
.gzcopy when there is one. The gzip_static module isn't built into nginx by default, so check that yours has it.
A warning about secrets
nginx's docs add one warning: "When using the SSL/TLS protocol, compressed responses may be subject to BREACH attacks." In a BREACH attack, someone guesses a secret on a page by watching how the size of its compressed responses changes. The attack's authors say a page is at risk when all three of these are true:
- the server compresses it;
- its body repeats text from the request, like a search term;
- its body holds a secret, "such as a CSRF token": a secret value a site uses to block forged requests.
The shop's bundle has neither of the last two, so it's safe to compress. A page that has all three needs more care.
Checking what arrived
nginx with just gzip on; looked set up, and still sent the bundle as it is. So don't trust a setting. Check what arrived. Two sizes tell you, like a packed suitcase and the pile of clothes on the bed once it's unpacked.
In DevTools
Open the Network panel and turn on large request rows. Chrome's docs: "Look at the Size column for the response you're interested in. The top value is the compressed size. The bottom value is the decompressed size." To see the format, click the request, open its Headers tab, and find content-encoding in the response headers.
In the page
Navigation Timing and Resource Timing showed that the browser keeps an entry for every file it fetches. Each entry has the sizes too. Read this table across: each row is one field, what it counts, and its value for the bundle when Brotli 11 compressed it at build time.
| Which field | What it counts | For the bundle |
|---|---|---|
encodedBodySize | The body as it travelled, still compressed. | 58,282 bytes. |
decodedBodySize | The body after the browser decompressed it. | 219,093 bytes. |
transferSize | The compressed body, plus a fixed 300 that stands in for the headers. | 58,582 bytes. |
contentEncoding | The format in Content-Encoding. It's empty when nothing was compressed. | br. |
MDN: "If the encodedBodySize and decodedBodySize properties are non-null and differ, the content was compressed." The transferSize was 300 bytes more than encodedBodySize in every visit. That 300 isn't the real size of the headers. MDN says it's a fixed stand-in, because the real size "could reveal information such as the presence of cookies."
Here is how the course's page read the sizes:
const [app] = performance.getEntriesByName(new URL("/app.4f2b.js", location.href).href);const compressed = app.encodedBodySize < app.decodedBodySize;sendToAnalytics({ encoding: app.contentEncoding, onTheWire: app.encodedBodySize, decoded: app.decodedBodySize, compressed });- Line 1 finds the bundle's Resource Timing entry by its full address.
- Line 2 compares the two sizes. Smaller on the wire means it was compressed.
- Line 3 sends the sizes and
contentEncodingto the shop's analytics.sendToAnalyticsstands for the shop's own function. Then the shop's field data shows what each visitor really got.
And here is what it read in two of the setups:
// What Resource Timing said about the bundle in two setups, the same in every run.const bundle = { "nginx, gzip on": { contentEncoding: "", encodedBodySize: 219093, decodedBodySize: 219093 }, "Brotli 11, at build time": { contentEncoding: "br", encodedBodySize: 58282, decodedBodySize: 219093 },}; for (const [setup, entry] of Object.entries(bundle)) { const format = entry.contentEncoding || "not compressed"; console.log(`${setup}: ${format}, ${entry.encodedBodySize} of ${entry.decodedBodySize} bytes on the wire`);}- Lines 3 and 4 are what Resource Timing said about the bundle. They were the same in every run.
- Line 8 uses "not compressed" when
contentEncodingis empty. Line 9 prints each setup. - It prints
nginx, gzip on: not compressed, 219093 of 219093 bytes on the wireandBrotli 11, at build time: br, 58282 of 219093 bytes on the wire.
With nginx's default, both sizes were the same: nothing was compressed. With the build step's Brotli copy, about a quarter of the bundle's bytes travelled.
Now try all eight setups. Pick one, and a link. The panel shows when the text appeared and when the bundle arrived. It shows how long the server spent compressing, and what Resource Timing said about the bundle and the page itself.
- LCP616 ms
- Bundle arrived598 ms
- Server compressing the bundle282.5 ms
- Bundle Content-Encodingbr
- Bundle on the wire58.3 kB
- Bundle decoded219.1 kB
- The page itselfnone, 377 B of 377 B
Node's server compressed the bundle with Brotli 11 for every request. Brotli's highest level: the smallest file, and the slowest to make. Times are the median of 5 runs. Sizes and formats were the same in every run.
How should it be compressed?
Every response needs one of three plans. Ask two questions. Is it the same for every visitor? Is it text that isn't compressed yet? Then sort each one:
- A JavaScript bundle with a hash in its name
- A stylesheet with a hash in its name
- An SVG icon sprite
- A product page made for each request
- JSON from a search API
- A JPEG product photo
- An MP4 product video
- A 16-byte JSON reply
Sort each response by how it should be compressed.
Common mix-ups
- "The highest level is always best." No. Compressed per request, Brotli 11 put the text on screen at 1,232 ms on the slow link. gzip 6 managed 1,000 ms. Save the top level for build time.
- "Compressing always makes the page faster." No. On the fast link, Brotli 11 per request gave 616 ms. Sending the bundle as it is gave 480.
- "A higher level makes the browser work harder." No. Decompressing took under a millisecond at every level. The level costs the side that compresses.
- "
gzip on;compresses everything." No. nginx compresses onlytext/htmluntil you list more types. It sent the bundle as it is. - "Compress every file." No. The photo shrank about 3%, the gzip copy grew, and a 16-byte reply became 36 bytes.
- "Every browser asks for Brotli." No. Over plain HTTP, Chrome asked for gzip, deflate only.
- "transferSize is the real size of the headers plus the body." No. It's the compressed body plus a fixed 300 that stands in for the headers.
Practice exercises
Answer first, then use the hints.
The course also tried Zstandard 3, compressed per request. What does the last line print?
// The slow link. The median LCP of 5 runs, in ms.
const sentAsItIs = 1760;
const perRequest = { "gzip 6": 1000, "Brotli 5": 988, "Zstandard 3": 1008 };
for (const [level, lcp] of Object.entries(perRequest)) {
console.log(`${level}, per request: ${sentAsItIs - lcp} ms sooner`);
}Zstandard 3, per request: 752 ms sooner. That's close to gzip 6 and Brotli 5: at a middle level, all three formats are quick enough to use per request.
The page also read the bundle with nginx set up. What does the last line print?
// What Resource Timing said about the bundle in two setups, the same in every run.
const bundle = {
"nginx, gzip on": { contentEncoding: "", encodedBodySize: 219093, decodedBodySize: 219093 },
"Brotli 11, at build time": { contentEncoding: "br", encodedBodySize: 58282, decodedBodySize: 219093 },
"nginx, gzip set up": { contentEncoding: "gzip", encodedBodySize: 68219, decodedBodySize: 219093 },
};
for (const [setup, entry] of Object.entries(bundle)) {
const format = entry.contentEncoding || "not compressed";
console.log(`${setup}: ${format}, ${entry.encodedBodySize} of ${entry.decodedBodySize} bytes on the wire`);
}nginx, gzip set up: gzip, 68219 of 219093 bytes on the wire. That's the build step's gzip 9 copy, which gzip_static sent as it was.
On the fast link, how long did the server spend compressing the bundle with Brotli 11, in ms?
282.5 ms, for every request. The whole bundle, sent as it is, arrived sooner than that, which is why compressing made the page slower.
With nginx and just gzip on, how many bytes did the page itself take on the wire?
278 bytes. nginx always compresses text/html, so the page was compressed even though the bundle wasn't.
Lighthouse flags text that wasn't compressed. Below what original size does it leave a response alone?
1.4 KiB. Lighthouse doesn't flag a smaller response, or one where compressing would save less than 10% of it.
For HTTP, what's the largest Window_Size a Zstandard encoder may require?
8 MB. For HTTP, encoders MUST NOT need a larger window, and decoders MUST handle one that size. That keeps the memory a browser needs in check.
Check your understanding
For each question, ask three things. How many bytes crossed the link? How long did the server spend making them? What did the browser say it could read?
Question 1 of 7What does a compressor write in place of text that has already appeared?
Choose an answer to see the explanation.
Question 2 of 7Over plain HTTP, Chrome sent a shorter Accept-Encoding. What does this server print?
Read the code, then predict// What Chrome sent over plain HTTP, and what this server can send, its favourite first. const acceptEncoding = "gzip, deflate"; const serverCanSend = ["br", "zstd", "gzip"]; const browserCanRead = acceptEncoding.split(", "); const chosen = serverCanSend.find((format) => browserCanRead.includes(format)); console.log(`Content-Encoding: ${chosen}`);Choose an answer to see the explanation.
Question 3 of 7What does the second line of this example print?
Read the code, then predict// Each format at a middle level: the size in bytes, // and the median time to compress the bundle of 9 tries, in ms. const middle = { "gzip 6": [68399, 4.1], "Brotli 5": [64434, 3.8], "Zstandard 3": [70299, 0.9], }; for (const [level, [size, ms]] of Object.entries(middle)) { console.log(`${level}: ${(size / 1000).toFixed(1)} kB in ${ms} ms`); }Choose an answer to see the explanation.
Question 4 of 7On the fast link, Brotli 11 per request gave a slower LCP than sending the bundle as it is. Why?
Choose an answer to see the explanation.
Question 5 of 7In this example, what does the first line print?
Read the code, then predict// A tiny JSON reply, {"inStock":true}, compressed two ways. Sizes in bytes. const reply = { size: 16, gzip: 36, brotli: 20 }; console.log(`gzip: ${reply.size} bytes became ${reply.gzip}.`); console.log(`Brotli: ${reply.size} bytes became ${reply.brotli}.`);Choose an answer to see the explanation.
Question 6 of 7nginx with just
gzip on;sent the JavaScript bundle uncompressed. Why?Choose an answer to see the explanation.
Question 7 of 7In the page, how can you tell that a file arrived compressed?
Choose an answer to see the explanation.
Key takeaways
- Compression writes repeats as short pointers back. Text repeats a lot: the bundle went from 219.1 kB to 58.3 kB, and the text appeared 804 ms sooner on the slow link.
- The browser lists what it can read in Accept-Encoding. The server names its choice in Content-Encoding, and adds Vary.
- Send Brotli or Zstandard to browsers that ask for them, and gzip to the rest.
- Use the highest level once, at build time. Per request, use a middle level such as gzip 6, Brotli 5 or Zstandard 3.
- Skip images, video, files that are already compressed, and tiny replies. Check your server's default types.
- Check what arrived: DevTools' Size column, or encodedBodySize against decodedBodySize.
Remember the one-liner.
Compress text once, as hard as you can, at build time; compress per request at a middle level; and check what arrived.
Sources
The facts on this page come from these standards and docs:
- Optimize the encoding and transfer size of text-based assets, web.dev.
- Compression in HTTP, MDN.
- RFC 1951: DEFLATE Compressed Data Format Specification, the IETF.
- RFC 1952: GZIP file format specification, the IETF.
- Enable text compression, Chrome for Developers.
- RFC 7932: Brotli Compressed Data Format, the IETF.
- RFC 8878: Zstandard Compression and the 'application/zstd' Media Type, the IETF.
- RFC 9659: Window Sizing for Zstandard Content Encoding, the IETF.
- RFC 9110: HTTP Semantics, the IETF.
- PerformanceResourceTiming: contentEncoding property, MDN.
- RFC 9111: HTTP Caching, the IETF.
- Module ngx_http_gzip_static_module, nginx.
- Module ngx_http_gzip_module, nginx.
- BREACH: SSL, gone in 30 seconds, Yoel Gluck, Neal Harris and Angelo Prado.
- CSRF, MDN.
- PerformanceResourceTiming: encodedBodySize property, MDN.
- PerformanceResourceTiming: decodedBodySize property, MDN.
- PerformanceResourceTiming: transferSize property, MDN.
Coming next: Compression dictionaries, also in Fast servers, caches, and networks. Brotli's built-in dictionary is the same for every site. A shared dictionary is your own: a new version of a file can point back into the old one, so only what changed travels.