completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

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.

By the end, you can
  • 01
    Pick a format and a levelSend Brotli or Zstandard when the browser lists them, gzip otherwise, at a level that fits when you compress.
  • 02
    Compress at the right momentUse the highest level once, at build time. Use a middle level for responses made per request.
  • 03
    Check 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:

The same bundle, sent two waysPop out in the code editor (opens in a new tab)JavaScript
// 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. and Compressed: 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:

The shop's server compresses the 219.1 kB bundle with Brotli 11. 58.3 kB travels over the link. Asha's browser decompresses it back to the same 219.1 kB of code.The shop's server219.1 kBthe bundle as builtcompressOn the wire58.3 kBBrotli 11decompressAsha's browser219.1 kBthe same code
The server compresses the bundle before it travels. The browser decompresses it as it arrives. Only the bytes on the wire are smaller.
Real-life analogyPacking a suitcase tightly

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.

The main idea

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.

Real-life analogyWriting "same as step 3"

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:

Following one pointer backPop out in the code editor (opens in a new tab)JavaScript
// 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 tea begins.
  • Line 7 copies 3 characters from there. Line 8 adds them to the end.
  • Lines 9 and 10 print The pointer copied "tea". and The 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 smallest each format made the bundlePop out in the code editor (opens in a new tab)JavaScript
// 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% smaller and Zstandard 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.

Real-life analogyAsking for a menu you can read

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 asks for the bundle with Accept-Encoding: gzip, deflate, br, zstd. The server answers with Content-Encoding: br and Vary: Accept-Encoding, 58.3 kB on the wire, which Chrome decodes to 219.1 kB.ChromeServerAccept-Encoding: gzip, deflate, br, zstd58.3 kB on the wireContent-Encoding: brVary: Accept-Encodingdecoded: 219.1 kB
Chrome lists the formats it can decompress. The server names the one it used, and adds Vary. Over plain HTTP, Chrome asked for gzip, deflate only.

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:

A server picks a formatPop out in the code editor (opens in a new tab)JavaScript
// 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-Encoding header 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:

What Brotli's top level buys, and costsPop out in the code editor (opens in a new tab)JavaScript
// 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:

Median time to compress the 219.1 kB bundle, of 9 tries, and the size it came out. gzip 1: 1.7 ms, 77.8 kB. gzip 6: 4.1 ms, 68.4 kB. gzip 9: 5.2 ms, 68.2 kB. Brotli 1: 0.9 ms, 77.0 kB. Brotli 5: 3.8 ms, 64.4 kB. Brotli 9: 7.7 ms, 63.0 kB. Brotli 10: 103.4 ms, 59.2 kB. Brotli 11: 272.7 ms, 58.3 kB. Zstandard 1: 0.6 ms, 74.4 kB. Zstandard 3: 0.9 ms, 70.3 kB. Zstandard 12: 12.3 ms, 63.6 kB. Zstandard 19: 47.1 ms, 61.5 kB.gzip 11.7 ms, 77.8 kBgzip 64.1 ms, 68.4 kBgzip 95.2 ms, 68.2 kBBrotli 10.9 ms, 77.0 kBBrotli 53.8 ms, 64.4 kBBrotli 97.7 ms, 63.0 kBBrotli 10103.4 ms, 59.2 kBBrotli 11272.7 ms, 58.3 kBZstandard 10.6 ms, 74.4 kBZstandard 30.9 ms, 70.3 kBZstandard 1212.3 ms, 63.6 kBZstandard 1947.1 ms, 61.5 kB
Each bar is the median time to compress the bundle, drawn to scale. The shaded bar, Brotli 11, is the slowest. Decompressing took 0.2 to 0.6 ms at every level.

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:

A middle level of each formatPop out in the code editor (opens in a new tab)JavaScript
// 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 ms and Zstandard 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.
Real-life analogyPacking the night before, or at the door

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, compressed at two momentsPop out in the code editor (opens in a new tab)JavaScript
// 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. and Compressed 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: Brotli 11 per requestPop out in the code editor (opens in a new tab)JavaScript
// 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. and Brotli 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:

Middle levels, per requestPop out in the code editor (opens in a new tab)JavaScript
// 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 sooner and Brotli 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:

Median LCP of 5 runs. Slow link, No compression: 1,760 ms. Slow link, gzip 6, per request: 1,000 ms. Slow link, Brotli 11, per request: 1,232 ms. Slow link, Brotli 11, at build time: 956 ms. Fast link, No compression: 480 ms. Fast link, gzip 6, per request: 348 ms. Fast link, Brotli 11, per request: 616 ms. Fast link, Brotli 11, at build time: 340 ms.Slow linkNo compression1,760 msgzip 6, per request1,000 msBrotli 11, per request1,232 msBrotli 11, at build time956 msFast linkNo compression480 msgzip 6, per request348 msBrotli 11, per request616 msBrotli 11, at build time340 ms
Each bar is the median LCP of 5 runs. The shaded bars are Brotli 11 compressed at build time: the fastest of the four 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:

Compress once, at build timeJavaScript
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 for br.
  • 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.

Real-life analogyA parcel that's already vacuum-sealed

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:

A photo compressed againPop out in the code editor (opens in a new tab)JavaScript
// 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 reply gets biggerPop out in the code editor (opens in a new tab)JavaScript
// 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. and Brotli: 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:

nginx, set upText
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 .gz copy 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.

Three sizes and a format, for one file
Which fieldWhat it countsFor the bundle
encodedBodySizeThe body as it travelled, still compressed.58,282 bytes.
decodedBodySizeThe body after the browser decompressed it.219,093 bytes.
transferSizeThe compressed body, plus a fixed 300 that stands in for the headers.58,582 bytes.
contentEncodingThe 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:

What arrived, read in the pageJavaScript
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 contentEncoding to the shop's analytics. sendToAnalytics stands 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:

Two setups, as the page saw themPop out in the code editor (opens in a new tab)JavaScript
// 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 contentEncoding is empty. Line 9 prints each setup.
  • It prints nginx, gzip on: not compressed, 219093 of 219093 bytes on the wire and Brotli 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.

Playground: one bundle, eight ways
What arrived
  1. LCP616 ms
  2. Bundle arrived598 ms
  3. Server compressing the bundle282.5 ms
  4. Bundle Content-Encodingbr
  5. Bundle on the wire58.3 kB
  6. Bundle decoded219.1 kB
  7. The page itselfnone, 377 B of 377 B
Try it yourself
Brotli 11, per request, fast link: LCP 616 ms

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.

Recorded in Chrome 156 and nginx 1.31.5 on Apple M1 Pro. Slow link: 150 ms round trip, 1.6 Mbps down. Fast link: 60 ms, 9 Mbps.

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:

How should it be compressed?
  • 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
Try it yourself
0 of 8 correct

Sort each response by how it should be compressed.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 only text/html until 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.

Exercise 1 · Warm-upZstandard, per request

The course also tried Zstandard 3, compressed per request. What does the last line print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// 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`);
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upnginx, set up

    The page also read the bundle with nginx set up. What does the last line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // 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`);
    }

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe server's time

      On the fast link, how long did the server spend compressing the bundle with Brotli 11, in ms?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThe page itself

        With nginx and just gzip on, how many bytes did the page itself take on the wire?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeToo small to flag

          Lighthouse flags text that wasn't compressed. Below what original size does it leave a response alone?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeZstandard's window

            For HTTP, what's the largest Window_Size a Zstandard encoder may require?

            Answer, then press Check. Spacing and letter case don’t matter.

              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?

              gzip, Brotli, and Zstandard quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does a compressor write in place of text that has already appeared?

                Choose an answer to see the explanation.

              2. Question 2 of 7Over plain HTTP, Chrome sent a shorter Accept-Encoding. What does this server print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              3. Question 3 of 7What does the second line of this example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              4. 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.

              5. Question 5 of 7In this example, what does the first line print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // 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.

              6. Question 6 of 7nginx with just gzip on; sent the JavaScript bundle uncompressed. Why?

                Choose an answer to see the explanation.

              7. 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:

              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.

              CompleteFrontend Clear concepts. Working examples.