Compression and minification
Two ways to send fewer bytes for the same code: minification rewrites JS and CSS into a shorter program at build time, and compression (gzip, Brotli, Zstandard) packs bytes for the trip, together often cutting text to a fifth of its size.
Why the bytes on the wire matter
Picture a team that ships a dashboard. Their main JavaScript file is 1.2 MB on disk in the build folder. Someone opens the network panel on a slow phone and sees the file take four seconds to arrive. Panic follows: should they rewrite the app, or switch frameworks?
Then someone notices the server is sending the file exactly as it sits on disk: every space, every comment, every long variable name, and no compression at all. Two settings later, the same file travels as about 200 KB. Nothing about the app changed: the browser still runs the same program, and only the bytes on the wire are different.
This lesson is about those two settings. Minification rewrites a text file (JavaScript, CSS, HTML, SVG) into a shorter file that means exactly the same thing. Compression packs any file into a smaller encoded form for the trip and unpacks it in the browser. They are different tools that do different jobs, and you almost always want both.
What each byte costs
For text files, download time sits right in front of the first render or the first interaction. Three costs add up:
- Transfer time. On a 1.6 Mbps slow 4G connection (the Lighthouse mobile profile), 1 MB takes about 5 seconds, so every 100 KB you cut saves about half a second.
- Data and money. Many users pay per megabyte.
- Round trips during slow start. A new TCP or QUIC connection starts by sending only about 14 KB in the first round trip, then doubles. A smaller file needs fewer of those round trips, so it gains more than its byte count suggests on a fresh connection.
Compression and minification cut transfer time and data. They do not cut everything. The browser still has to parse and run the full program after it unpacks it, so a 1 MB bundle that travels as 200 KB still costs 1 MB of parsing: about a second of main-thread work on a mid-range phone, by the estimation lesson's rule of thumb. That is why code splitting and budgets matter too: they remove code, while these tools only make the same code travel lighter.
Two sizes for every file
From now on, keep two numbers in mind for every text file. The raw size (also called parsed or uncompressed size) is what the browser has after unpacking: what it parses and keeps in memory. The transfer size (or encoded, compressed, or wire size) is what crosses the network.
When someone says "our bundle is 180 KB", the first question is always: raw, gzip, or Brotli? The three can differ by a factor of five. The performance budgets lesson uses Brotli size by default because that is closest to what real users download.
Check your understanding · Exercise 1
A 900 KB JavaScript bundle is minified and served with Brotli, and now transfers as 190 KB. Which costs went down? Select all that apply.
The mental model: edit the letter, then vacuum-pack it
Think of sending a long letter by post. First you edit it: cut the filler, shorten the long words, drop the paragraphs nobody reads. The result is a shorter letter that still says the same thing, and the reader can read it as is. That is minification.
Then you vacuum-pack the envelope so it takes less room in the van. The reader opens the pack and gets back exactly the letter you packed. That is compression. Editing first just leaves less to pack.
Two jobs, two places, two owners
| Question | Minification | Compression |
|---|---|---|
| What it changes | The source text itself: a new, shorter program | Only the encoding of the bytes for the trip |
| Is the output still runnable code? | Yes, the browser runs it directly | No, it must be decoded first |
| Reversible? | No: comments and original names are gone (source maps map back) | Yes, exactly, byte for byte |
| Which files | Text the browser parses: JS, CSS, HTML, SVG, JSON | Any response, but only text gains much |
| When it runs | Once, at build time | At build time (static) or per request (dynamic) |
| Who does it | Your bundler: Vite, webpack, Next.js, esbuild | Your server or CDN, steered by HTTP headers |
| Typical saving on a JS bundle | 40% to 60% of the raw size | 70% to 90% of what is left |
The last row is why both matter. In the measurements later in this lesson, the React DOM production file goes from 503 KB to 242 KB by minification, then to 51 KB with Brotli. Each step removes a different kind of waste, so they stack.
They remove different kinds of waste
Minification removes waste that only a tool that understands the language can see. It knows that calculateTotalPrice is a local variable that nobody outside the function can name, so it can become t. It knows that if (false) { ... } will never run, so the whole block can go. A generic compressor cannot know any of that: it sees bytes, not code.
Compression removes waste that has nothing to do with meaning: repetition. JavaScript is full of it: function, return, this., .prototype., the same property names again and again. A compressor replaces each repeat with a short pointer back to an earlier copy, then stores common symbols in fewer bits. It works on any text, in any language, without understanding it.
Think first
Could you compress first and minify the compressed output instead?
No. Compressed bytes are not JavaScript, so a minifier cannot parse them. And even if you minify after decompressing, you would have to compress again anyway.
The order is fixed: minify at build time to get a shorter program, then compress that program for the trip. The browser reverses only the second step: it decodes the compressed bytes and runs the minified code as is.
Minification in detail
A minifier parses your code into a syntax tree, the same structure the browser builds, then prints that tree back out in the shortest form it can. Because it works on the tree and not on the text, it can make changes that are safe by the rules of the language. Here is a small function before and after Terser, with mangling and compression on.
// Before: 305 bytes
/** Adds tax to a cart total. */
export function totalWithTax(cart, taxRate) {
const DEBUG = false;
let subtotal = 0;
for (const item of cart.items) {
subtotal += item.price * item.quantity;
}
if (DEBUG) {
console.log("subtotal", subtotal);
}
return subtotal + subtotal * taxRate;
}
// After: 100 bytes
export function totalWithTax(t,o){let e=0;for(const o of t.items)e+=o.price*o.quantity;return e+e*o}
totalWithTax and the properties items, price, and quantity survive, because other code reaches them by name. Everything local is fair game.The five things a minifier does
- Drops whitespace and comments. Indentation, line breaks, and doc comments are for humans. This is the safest step and on its own often saves 20% to 30% of a hand-written file.
- Renames local names (mangling).
subtotalbecomesebecause nothing outside the function can refer to it. Exported names, globals, and object properties likeitem.pricestay as they are, because other code reaches them by name. - Removes dead code.
DEBUGis a constantfalse, so theifblock can never run and is removed, along with the now unused constant. This is the step that strips development-only warnings from libraries. - Rewrites into shorter equivalents.
truebecomes!0,undefinedbecomesvoid 0, anifwith one return becomes a ternary, consecutivevarlines merge, and constant expressions like60 * 60 * 1000fold into36e5. - Keeps what it must. License comments marked with
@license,@preserve, or a leading/*!stay by default in Terser, and anything with side effects stays, because removing it would change behaviour.
Dead code, build flags, and tree shaking
The biggest single win from minification is often not the renaming but dead code removal driven by build flags. Libraries wrap development checks in if (process.env.NODE_ENV !== "production"). The bundler replaces that expression with the literal string at build time, so the condition becomes "production" !== "production", which the minifier folds to false and then deletes, along with every warning message inside.
Tree shaking is related but done by the bundler before minification: it drops whole exports that no module imports. The two work together. The bundler marks unused code, and the minifier does the final sweep. A call the minifier cannot prove is free of side effects stays, which is why libraries add /*#__PURE__*/ before calls like createContext() at the top level: it tells the minifier the call can go if its result is unused.
React shows the effect. Its development build of the client DOM package is about 928 KB; the production build, with dev checks removed but not yet minified, is about 503 KB. Terser then takes that to 242 KB. Ship the development build by accident and you send nearly four times the code.
The tools: Terser, esbuild, SWC
| Tool | Written in | Where you meet it | Trade-off |
|---|---|---|---|
| Terser | JavaScript | webpack production mode default; Vite and Rollup option | Very thorough output, the slowest of these on big bundles |
| esbuild | Go | Vite's default up to Vite 7; many tools | Many times faster; output usually within a few percent of Terser |
| Oxc minifier | Rust | Vite 8 default, through Rolldown | Very fast, output close to Terser |
| SWC | Rust | Next.js default minifier; Parcel; Rspack | Fast, and close to Terser in size |
| cssnano | JavaScript (PostCSS) | PostCSS pipelines; webpack's CSS minimizer plugin | Many safe CSS rewrites, slower |
| Lightning CSS | Rust | Parcel; Vite 8's default CSS minifier | Very fast CSS parse, minify, and prefixing |
In our measurement, Terser took about 760 ms to minify the 503 KB React DOM file. On a large app with hundreds of chunks that adds up to minutes, which is why the ecosystem moved to native tools: slightly larger output in some cases, but seconds instead of minutes.
CSS, HTML, and SVG
CSS minifiers drop whitespace and comments, shorten colours (#ffffff to #fff), drop units on zero (0px to 0), drop the last semicolon in a block, and merge duplicate rules where order allows. They do not remove selectors your pages never use: that is a separate step (Tailwind's class scanning, or PurgeCSS), and it is often the bigger saving.
HTML minification saves less, often 5% to 15%. SVG is worth a pass with SVGO: design tools export editor metadata and long decimal paths that it removes safely.
Source maps: debugging minified code
A stack trace pointing at line 1, column 48,213 of app.3f9a.js is useless. A source map is a JSON file that maps every position in the minified output back to the original file, line, and name. The bundler writes it next to the bundle and either links it with a //# sourceMappingURL= comment or leaves the link out (a hidden source map) and uploads it to your error tracker.
Check your understanding · Exercise 2
Which names in a bundle can a minifier safely rename by default? Select all that apply.
How compression works: gzip, Brotli, Zstandard
Two ideas sit inside every web compressor. Knowing them explains most rules in this lesson: why text shrinks and images don't, and why Brotli beats gzip.
Idea one: replace repeats with pointers
The compressor reads the file from start to end and keeps the recent past in a window. Whenever the next few bytes already appeared in the window, it writes a short pointer instead: "go back 37 bytes and copy 12". This is called LZ77, after the 1977 paper by Lempel and Ziv. The decoder does the opposite: it copies those 12 bytes from what it has already rebuilt.
Code is full of repeats. In this.state.items.map(item => item.id) the word item appears three times; across a bundle, function, return, .length, and your own property names repeat thousands of times. Each repeat becomes a pointer of 2 to 3 bytes. The bigger the window, the further back the compressor can look for a match.
Replacing repeats with pointersStep 1 of 6: Nothing encoded yet
item.id;item.name;item.id from left to right: 25 characters. It remembers what it has seen in a window.Nothing encoded yet: The compressor reads item.id;item.name;item.id from left to right: 25 characters. It remembers what it has seen in a window.
Idea two: short codes for common symbols
After the first pass, the output is a stream of plain characters (literals) and pointers. Some of them are far more common than others: in code, e, t, space, and ( show up much more than Q or ~. Huffman coding gives the common symbols short bit codes and the rare ones long codes, like Morse code gives E a single dot. The compressor stores the code table at the start of each block so the decoder can read it.
That's all DEFLATE is: LZ77 with a 32 KB window, then Huffman coding. gzip is DEFLATE plus a small header and a checksum, about 18 bytes in total. Every HTTP client understands it, so it is the safe fallback.
Brotli: bigger window, smarter codes, built-in dictionary
Brotli came from Google in 2015 and became RFC 7932 in 2016. Every current browser supports it, but only over HTTPS: browsers advertise it only on secure connections, partly because old proxies on plain HTTP mangled unknown encodings. It keeps the same two ideas and improves each one:
- A much bigger window. Up to 16 MB instead of 32 KB, so a repeat far back in a large bundle can still be found.
- Context modeling. It keeps several Huffman tables and picks one based on the previous bytes. After a
., a letter is likely; after a(, maybe a quote. Better guesses mean shorter codes. - A static dictionary. The decoder ships with a built-in dictionary of about 120 KB: common words and phrases from text in many languages, plus fragments of HTML, CSS, and JavaScript. A pointer can refer to a dictionary entry even if the word never appeared earlier in the file. That is why Brotli wins most on small and medium text files, where a gzip window has not seen enough yet.
The price is time to compress at the top levels. Decompression stays fast at every level, which matters: the server compresses once, while millions of phones decompress.
Zstandard: the fast newcomer
Zstandard (zstd) came from Facebook in 2016, is specified in RFC 8878, and is already used widely for disks, databases, and packages. Its strength is speed: at its default level 3 it compresses nearly as well as gzip at level 6, but several times faster, and it decompresses faster than both. At high levels it approaches Brotli's size. Chrome and Edge added zstd as an HTTP content encoding in version 123 (2024), Firefox in 126 (2024), and Safari in 26.3 (early 2026, on macOS and iOS 26.3 or later).
Zstandard's best fit is dynamic compression: responses compressed on every request, like HTML from server rendering and API JSON, where compression speed is the cost that matters. For static files compressed once at build time, Brotli at its top level still usually produces the smallest file. The numbers in the measuring chapter show both.
| Question | gzip | Brotli | Zstandard |
|---|---|---|---|
| Token in HTTP headers | gzip | br | zstd |
| Spec | RFC 1952 (DEFLATE: RFC 1951) | RFC 7932 | RFC 8878 (HTTP limits: RFC 9659) |
| Window | 32 KB | Up to 16 MB | Up to 8 MB when used over HTTP |
| Built-in dictionary | None | About 120 KB of web text | None built in |
| Levels | 1 to 9 (default 6) | 0 to 11 | Negative fast levels, 1 to 19 (to 22 with extra memory) |
| Browser support | Every client | All current browsers, HTTPS only | Chrome and Edge 123+, Firefox 126+, Safari 26.3+ |
| Best use | Universal fallback | Static text assets at level 11; dynamic at 4 to 6 | Dynamic responses where speed matters |
Check your understanding · Exercise 3
A 3 KB JSON response compresses noticeably better with Brotli than with gzip. Why? Select all that apply.
How the browser and server agree: Content-Encoding
The browser and the server never assume compression. They agree on it for every response, using three headers. This is called content negotiation, and getting one header wrong here causes some of the strangest bugs in this lesson.
GET /assets/app.3f9a1c.js HTTP/2
Host: shop.example
Accept-Encoding: gzip, deflate, br, zstd
HTTP/2 200
Content-Type: text/javascript
Content-Encoding: br
Content-Length: 52030
Vary: Accept-Encoding
Cache-Control: public, max-age=31536000, immutable
The three headers, one by one
Accept-Encoding(request): the encodings this client can decode. Chrome on HTTPS sendsgzip, deflate, br, zstd. On plain HTTP, browsers leave outbrandzstd. Command line tools often send none at all:curlasks for nothing unless you pass--compressed.Content-Encoding(response): the encoding the server actually applied to this body. The browser decodes it before anything else sees the bytes. No header means the body is sent as is, which the spec callsidentity.Vary: Accept-Encoding(response): tells every cache between server and browser that this URL has different bodies depending on the request'sAccept-Encoding. A cache must store them separately and hand each client a variant it can decode.
Vary: Accept-Encoding makes caches keep one copy per encoding, so a gzip-only client never gets Brotli bytes.How the server picks
The server reads the list, keeps the encodings it can produce for this file, and picks its favourite. Most servers and CDNs pick br when it is offered, some prefer zstd for dynamic responses, and gzip is the fallback.
The server also checks the file type. It only compresses types on an allow list, typically text/html, text/css, text/javascript, application/json, image/svg+xml, and similar. A JPEG or a WOFF2 font goes out as is, for reasons the next chapters explain.
What your JavaScript sees
Decoding happens inside the browser's network stack, before your code runs. await response.text() from fetch returns the decoded text. You never call a Brotli decoder yourself for a normal HTTP response.
The sizes are visible though. Each entry from performance.getEntriesByType("resource") has encodedBodySize (the compressed body), decodedBodySize (after decoding), and transferSize (encoded body plus headers, or 0 when served from the cache). The ratio of the first two is the real compression ratio your users got. For cross-origin files, these read as 0 unless the server sends Timing-Allow-Origin.
Vary and caches
Forget Vary: Accept-Encoding and a shared cache can store the first variant it sees and serve it to everyone. A CDN that cached a Brotli body would then hand Brotli bytes to a client that only understands gzip, and that client shows garbage or fails to parse the script. The bug hides until an old client, a crawler, or a monitoring tool hits it.
The opposite risk is fragmentation: every slightly different Accept-Encoding string becomes its own cache entry. CDNs solve this by normalizing the header to a few buckets (for example: Brotli capable, gzip only, none) before using it as part of the cache key. If you run your own cache layer, do the same.
Think first
You run curl -I https://shop.example/app.js and see no Content-Encoding header. Does that mean the site does not compress its JavaScript?
Not necessarily. Plain curl sends no Accept-Encoding header, so a correct server answers with the uncompressed body. The server did exactly what it should.
To test what a browser gets, ask the way a browser asks: curl -sI -H "Accept-Encoding: br, gzip" https://shop.example/app.js, then look for Content-Encoding: br and Vary: Accept-Encoding in the response.
Levels, and compressing at build time versus per request
Every compressor has a level: how hard it searches for matches. Higher levels search longer and find a few more matches, so the file gets a bit smaller and compression gets slower, sometimes much slower. Decompression speed barely changes. The right level depends on one question: do you compress this file once, or on every request?
Measured: one real bundle at several levels
To get real numbers, we took React DOM's client production file from React 19.1 (503 KB, not minified), minified it with Terser (242 KB), and compressed the minified file with Node 22's built-in zlib at several levels. Times are the best of three runs on one Apple-silicon laptop core. Your numbers will differ by machine and by file, but the shape holds.
| Setting | Output size | Versus minified | Compress time | Decompress time |
|---|---|---|---|---|
| Minified, no compression | 242 KB | 100% | 0 ms | 0 ms |
| gzip level 1 | 71 KB | 29% | 1.5 ms | 0.5 ms |
| gzip level 6 (default) | 61 KB | 25% | 3.8 ms | 0.5 ms |
| gzip level 9 | 61 KB | 25% | 4.6 ms | 0.5 ms |
| Brotli level 5 | 57 KB | 23% | 3.5 ms | 0.5 ms |
| Brotli level 9 | 55 KB | 23% | 6.7 ms | 0.5 ms |
| Brotli level 11 (max) | 51 KB | 21% | 269 ms | 0.6 ms |
| Zstandard level 3 (default) | 63 KB | 26% | 0.8 ms | 0.3 ms |
| Zstandard level 19 | 54 KB | 22% | 54 ms | 0.2 ms |
Four things stand out. Brotli 11 is about 17% smaller than gzip 6, zlib's default level and a common server setting, but it costs about 70 times more CPU to get there: 269 ms instead of 4 ms. gzip 9 buys almost nothing over gzip 6.
And decompression is under a millisecond for every option, so the browser's side of the cost is never the problem.
Static compression: once, at build time
Hashed build files like app.3f9a1c.js never change after the build. So compress them once, at the highest level, and save the results next to the original: app.3f9a1c.js.br and app.3f9a1c.js.gz. The server then just picks the right file for each request: no CPU at request time, and the best ratio money can buy. 269 ms per file is nothing in a build that runs once per deploy.
Most stacks support this. nginx has gzip_static on; and, with the Brotli module, brotli_static on;. Vite and webpack have plugins that write .br and .gz files during the build. Many CDNs and hosting platforms either compress static assets themselves at a high level and cache the result, or pass through a precompressed body from your origin when it arrives with a Content-Encoding header.
Dynamic compression: per request
Server-rendered HTML and API JSON are different for every request, so they must be compressed on the fly. Now compression time adds directly to TTFB (the time to the first byte) and uses CPU on every request. Here Brotli 11 is the wrong choice: 269 ms per response would hurt more than the bytes it saves.
Use a mid level: Brotli 4 to 5, gzip 6, or Zstandard 3. In the table, Brotli 5 is already smaller than gzip 6 and just as fast.
Where you compress matters too. A Node.js server that compresses with middleware does it on the same thread that renders pages, so under load let a reverse proxy or the CDN do it.
Decision Where should a typical web app compress its responses?
What we pick: Do both of the first two. Precompress hashed JS, CSS, and SVG at build time with Brotli 11 and gzip 9, and let the CDN or reverse proxy compress dynamic HTML and JSON at a mid level (Brotli 4 to 5, or Zstandard 3 where clients support it). Compress in the app server only when there is nothing in front of it.
React DOM's client file from React 19.1. Minified with Terser.
51 KB on the wire, 10% of the original and 79% smaller than without compression. Compressing took 269 ms, decoding under 1 ms. On slow 4G (1.6 Mbps) the download takes about 260 ms.
Check your understanding · Exercise 4
Which settings make sense? Select all that apply.
What not to compress
Compression only helps when there is repetition left to find. Some files have none, because they are already compressed. Some are so small that the encoding overhead is bigger than the saving, and in a few cases compression leaks secrets. Knowing these three cases is what separates turning compression on from configuring it.
Images, fonts, video: already compressed
Modern media formats have their own compression built in, tuned for their kind of data. PNG uses DEFLATE inside. JPEG, WebP, and AVIF use image codecs that also throw away detail the eye won't miss. WOFF2 fonts are compressed with Brotli inside the file, as the fonts lesson explains, and MP4 and WebM video, MP3 and Opus audio, and ZIP files are the same story.
Compressed output looks like random noise to a compressor: no repeats, every byte equally likely. Running gzip over it gains nothing and wastes CPU. We measured this directly: gzip level 9 over our 51 KB Brotli file made it 38 bytes bigger.
That's why servers compress by content type and skip image/jpeg, image/webp, image/avif, font/woff2, and video/mp4 entirely. Making images smaller is a different job, covered in the image optimization lesson: better formats, right sizes, and quality settings.
One honest exception shows up in the lab in the previous chapter. A PNG social card exported by a design tool shrank about 12% with gzip, because the tool used PNG's built-in DEFLATE weakly. The right fix is still not HTTP compression: re-encode the image once with a PNG optimizer, or switch to WebP or AVIF, and you get more savings with no CPU spent on every request.
The exceptions are image and font formats that are really text or uncompressed data. SVG is XML text and compresses very well, often by 60% to 80%, so it belongs on the allow list. Old TTF and OTF fonts and the rare BMP or ICO file also shrink a lot, though the better fix is to convert them to WOFF2 or a modern image format.
Tiny responses
Every encoded body carries a few bytes of overhead, so a very small body has nothing to gain. We measured a 32-byte JSON body, {"id":4812,"ok":true,"unread":3}: gzip made it 52 bytes and Brotli 36. Both bigger than the original.
Even when a small body does shrink, saving 300 bytes on a 900-byte response changes nothing the user can feel: both fit in the same network packet. So servers and CDNs skip compression below a minimum size. The threshold varies: Express's compression middleware defaults to 1 KB, while nginx's gzip_min_length defaults to just 20 bytes, so about 1 KB is a sensible setting rather than a standard. A short JSON acknowledgement should go out as is.
When compression leaks secrets: BREACH
Compression makes the output length depend on the content: more repeats, shorter output. In 2013 researchers showed an attack called BREACH that turns this into a leak. If a response holds a secret (a CSRF token) and also echoes attacker input (a search term from the URL), the attacker can guess the secret one character at a time: a right guess repeats part of it, so the response gets shorter, and length is visible even through HTTPS.
The attack needs a secret and attacker-controlled input in the same compressed body, plus many requests whose sizes the attacker can observe. The usual defences keep compression on:
- Mask secrets per response: XOR the CSRF token with a fresh random value each time, so its bytes never repeat. Several web frameworks do this for their CSRF tokens.
- Keep secrets out of responses that reflect user input, or don't compress those specific responses.
- Use
SameSitecookies, so cross-site requests that the attacker triggers don't carry the session at all.
Check your understanding · Exercise 5
Which content types belong on the server's compression allow list? Select all that apply.
Compression dictionaries: shipping only what changed
Here is a waste that neither minification nor normal compression can touch. A user visited yesterday and has app.3f9a1c.js in their cache. Today you deploy a fix that changes a few lines. The bundle gets a new hash, app.8b20e4.js, and the user downloads all of it again, even though 98% of the bytes are the same as the file already sitting on their disk.
Brotli's built-in dictionary showed the idea: if the decoder already has some text, the encoder can point at it instead of sending it. Compression Dictionary Transport takes this further. It lets a site tell the browser "keep this response as a dictionary", and then compress later responses against it. The best dictionary for the new version of a file is the old version of the same file.
How it works, step by step
- First visit. The server sends
app.3f9a1c.jsas usual, plus a header:Use-As-Dictionary: match="/assets/app.*.js". The browser caches the file and remembers it as a dictionary for any future URL matching that pattern. - Next visit, new version. The page now asks for
app.8b20e4.js. That URL matches the pattern, so the browser adds two things to the request:dcbanddczinAccept-Encoding, andAvailable-Dictionarywith the SHA-256 hash of the stored file. - Server encodes the difference. If the server still has the file with that hash, it compresses the new file with Brotli (
dcb) or Zstandard (dcz) using the old file as the dictionary. It answers withContent-Encoding: dczandVary: Accept-Encoding, Available-Dictionary. - Browser rebuilds the file. It decodes the response using the stored dictionary and gets the full new bundle. If the server doesn't recognize the hash, it falls back to normal
brorgzip, so nothing breaks.
Measured: one version to the next
To see the size of the win, we compared two real releases. We minified React DOM's client file from React 19.1.0 and from a 19.2.0 canary build, a real upgrade with new features and many changed lines. Compressed alone with Brotli 11, the new version is about 52 KB. Compressed with Zstandard level 19 using the 19.1 file as its dictionary, it is about 3.5 KB: 93% smaller than the best normal compression.
A typical deploy changes far less than a library upgrade, so your own deltas are often smaller still.
Shared dictionaries for HTML and API responses
A site can also build one dictionary from typical pages (shared header, footer, and markup), point the browser at it with link rel="compression-dictionary", and compress every page against it. Small dynamic responses gain the most.
Support and caveats
- Browsers. Chrome and Edge shipped it in version 130 (late 2024). The protocol is now an IETF standard (RFC 9842, September 2025). As of late 2026, Firefox has it only behind a preference and Safari not at all, so treat it as a progressive enhancement: those browsers get normal Brotli.
- Server work. The server or CDN must keep recent old versions and encode against the right one. Some CDNs offer this; doing it yourself means precompressing deltas from the last few releases at build time.
- Caching. Responses vary on
Available-Dictionary, so caches hold more variants. Only dictionaries from the same origin are used, and the browser enforces CORS rules, so one site cannot read another's dictionary.
Think first
Your app ships a 300 KB main bundle and deploys 10 times a day. Users return several times a day. Without dictionaries, what is a cheaper way to avoid re-downloading unchanged code after each deploy?
Split the bundle so that code which changes rarely lives in separate files with stable hashes: a vendor chunk for libraries, and route chunks for each page. A deploy that touches one route then invalidates only that chunk, and the browser cache keeps the rest.
This is the code splitting and caching approach, and it works in every browser. Dictionaries go one step further: they make even the changed chunk cheap to fetch. The two combine well.
Measuring real savings and fixing what breaks
Compression silently stops working. A new route bypasses the CDN or a content type changes, and nobody notices because the site still works, just slower. So check that users get the bytes you think you send.
Where to look
- DevTools Network panel. Compare "transferred" and "resources" at the bottom, and check each file's
Content-Encodingheader. - Lighthouse. The minify CSS and minify JavaScript diagnostics flag files that look unminified. Since Lighthouse 13, the document request latency insight checks that the HTML document is compressed; for scripts and styles, use the checks below.
- Resource Timing in RUM. Log
encodedBodySizeanddecodedBodySizefor your main bundles. A ratio near 1 means some users get them uncompressed, which catches regional CDN or proxy problems a lab test never sees. - Size checks in CI. Tools like
size-limitreport Brotli size by default and fail the build past the budget, as the performance budgets lesson shows. curlwith a real header.curl -sI -H "Accept-Encoding: br, gzip" URLshows exactly what the server negotiates, from any network you choose.
What breaks, how you notice, how you fix it
| Failure | How you notice | Fix |
|---|---|---|
| Some files go out uncompressed | Transferred equals resource size; no Content-Encoding | Check the server's content type allow list; a JS file served as application/octet-stream is skipped |
| Development build shipped | Bundle several times the expected size; console shows dev warnings | Set the production mode or NODE_ENV=production in the build |
Missing Vary: Accept-Encoding | Rare clients get unreadable files from a shared cache | Send Vary on every compressed response; let the CDN normalize the header |
| Double compression | Script fails to parse or the browser reports a decoding error; the body was encoded twice | Compress in exactly one layer; disable it in the app when a proxy or CDN does it |
| Brotli only at the edge | Edge hits are small, origin misses are big | Precompress at build so origin responses are already Brotli |
| Streaming HTML arrives all at once | Waterfall shows one late HTML chunk | Flush the compressor on each chunk, or move compression to a layer that streams |
Wrong Content-Length | Truncated files or hung requests | Let the layer that compresses set the length, or use chunked framing |
In real products and frameworks
Modern stacks do most of this for you, which is exactly why it is easy to break without noticing. Production builds from Vite, webpack, Next.js, and similar tools minify JS and CSS by default. Next.js minifies with SWC, and its built-in server (next start) compresses responses with gzip unless you turn the compress option off, usually because a proxy in front already does it. Hosting platforms and CDNs such as Vercel, Netlify, Cloudflare, and Fastly offer Brotli for text at the edge, often on by default.
Large sites go further. They precompress static assets at the top Brotli level, choose dynamic levels per content type, and test Zstandard for dynamic responses. Sites that deploy many times a day are the early users of compression dictionaries, because they gain the most from sending only the delta.
Check your understanding · Exercise 6
RUM shows that for 4% of sessions, encodedBodySize of vendor.js equals its decodedBodySize. Lab tests all show Brotli. What are plausible causes? Select all that apply.
Using it in a system design interview
Compression and minification rarely make a whole interview, but they come up in almost every one that touches performance: "the app is slow on mobile", "design the asset pipeline", "how would you keep the bundle small?". Getting the basics fast and precise leaves time for the parts that are specific to the problem.
When to bring it up
- When you list non-functional requirements and mention a load-time or bundle budget: say the budget is measured as Brotli transfer size.
- When you draw the delivery path (build, CDN, browser): mark where compression happens for static assets and for dynamic HTML or API responses.
- When asked about a slow first load: check this before proposing a rewrite, because it is cheap, and say so.
What to say, by level
| Level | What a strong answer includes |
|---|---|
| Junior | Minify JS and CSS in the production build; serve text with gzip or Brotli; images and fonts are already compressed; know raw versus transfer size |
| Mid | Brotli versus gzip and the roughly 15% to 20% gain on JS; static Brotli 11 at build versus mid-level dynamic; Content-Encoding and Vary: Accept-Encoding; skip small and binary responses; check with DevTools and Lighthouse |
| Senior | Where compression lives in the architecture and its CPU cost; streaming SSR and flushing; Zstandard for dynamic traffic; compression dictionaries for frequent deploys; BREACH and secret masking; RUM checks on encoded versus decoded sizes |
Keep it short unless the interviewer digs in. Two sentences cover most of it: "Static assets are minified and precompressed with Brotli at build time, with gzip as fallback, and served from the CDN with long cache lifetimes. Dynamic HTML and JSON are compressed at the edge at a mid level, and media is never recompressed." Then move on, and offer depth if asked.
Check your understanding · Exercise 7
An interviewer asks how to make a 1.5 MB (parsed) JS bundle load faster on mid-range phones. Which statements are accurate? Select all that apply.
Summary
- Minification rewrites code into a shorter program at build time; compression encodes bytes for the trip and is reversed exactly by the browser. Use both: they remove different waste.
- Always say which size you mean: raw (what the browser parses) or transfer (what the network carries, and with which encoding). Budgets use Brotli transfer size.
- Brotli beats gzip by about 15% to 20% on JS thanks to its bigger window, context modeling, and built-in dictionary. Zstandard is the fast option for dynamic responses and is now in all major browsers.
- Compress static hashed assets once at build time with Brotli 11; compress dynamic HTML and JSON per request at a mid level at the edge. Decompression is cheap at every level.
- Negotiation runs on
Accept-Encoding,Content-Encoding, andVary: Accept-Encoding. A missingVaryor double compression breaks things for some users only. - Don't compress images, fonts, video, or tiny responses: they are already compressed or too small to gain. Do compress SVG, JSON, HTML, CSS, and JS.
- Compression dictionaries let Chromium browsers download only the delta from a cached previous version, often a few KB instead of tens of KB.
- Verify in production with DevTools, Lighthouse, Resource Timing (
encodedBodySizeversusdecodedBodySize), and size checks in CI.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
What is the difference between minification and compression, and why do you need both?
Minification rewrites the source at build time into a shorter program that runs as is: it drops whitespace and comments, renames local variables, and removes dead code. It understands the language, and it is not reversible, which is why we keep source maps.
Compression encodes the bytes for the trip with gzip, Brotli, or Zstandard, and the browser decodes them back to the exact same bytes. It understands nothing about code; it finds repeats and gives common symbols short codes.
They remove different waste, so they stack. On React DOM's client file, minification took 503 KB to 242 KB and Brotli then took it to 51 KB. Minification also reduces what the browser parses; compression only reduces what it downloads.
Question 2
gzip or Brotli? And what level?
Brotli where the client supports it, which is every current browser over HTTPS, with gzip as the fallback. Brotli has a larger window, context modeling, and a built-in dictionary of web text, and on JS bundles it is usually 15% to 20% smaller than gzip at its default level.
The level depends on whether you compress once or per request. Hashed static assets: Brotli 11 at build time, because it is compressed once and decoded millions of times. Dynamic HTML and JSON: Brotli 4 to 5, gzip 6, or Zstandard 3, because compression time adds to TTFB on every request. Decompression is fast at every level.
Question 3
Walk me through how the browser and server agree on compression.
The browser lists what it can decode in Accept-Encoding, for example gzip, deflate, br, zstd. The server picks one it supports for that content type, encodes the body, and says which in Content-Encoding: br.
It also sends Vary: Accept-Encoding so shared caches store each encoding separately and never hand Brotli bytes to a client that only accepts gzip. CDNs normalize the header into a few buckets to avoid too many cache variants. The browser decodes before any script sees the response.
Question 4
Should we gzip our images and fonts too?
No. JPEG, PNG, WebP, AVIF, WOFF2, and video are already compressed inside the format, so a second pass finds no repetition, costs CPU, and can even add a few bytes. Servers compress by content type and leave those out.
The exception is SVG, which is XML text and compresses very well. To make images and fonts smaller, use better formats, right sizes, and subsetting instead.
Question 5
Our app deploys ten times a day and returning users keep downloading the full bundle. What can we do?
First, split the code so stable parts (libraries, rarely changed routes) live in separately hashed chunks with long cache lifetimes. A deploy then invalidates only the chunks that changed.
Second, in Chromium browsers, use Compression Dictionary Transport: mark bundles with Use-As-Dictionary, and compress the new version against the cached old one with dcb or dcz. In our measurement, a React DOM upgrade went from about 52 KB with Brotli to about 3.5 KB as a dictionary delta. Other browsers fall back to normal Brotli.
Practice it
These design problems lean on compression and minification.
References
- MDN: Compression in HTTP Overview of content encodings and negotiation.
- MDN: Content-Encoding header Tokens, including
br,zstd,dcb, anddcz. - MDN: Compression Dictionary Transport How
Use-As-DictionaryandAvailable-Dictionarywork. - RFC 7932: Brotli Compressed Data Format The Brotli format, including its static dictionary.
- RFC 8878: Zstandard Compression and the application/zstd Media Type
- RFC 9842: Compression Dictionary Transport The standard behind
Use-As-Dictionary,dcb, anddcz. - web.dev: Minify and compress network payloads Practical guide to text compression and minification.
- Chrome for Developers: Compression dictionary transport Chrome's guide with examples.
- Terser documentation Minify options such as
keep_classnamesand comment handling.
Read next
- Code splitting and bundlingBreaking one big JavaScript bundle into smaller pieces that load only when needed, because every kilobyte of JavaScript costs download, parse, and run time on the user's device.
- Performance budgets and monitoringAgreed limits on what a page may cost (kilobytes of JavaScript, LCP, INP) that CI checks on every pull request and real-user monitoring watches in production, so speed stops getting worse one small change at a time.
- Back-of-the-envelope numbers for frontendQuick, rough math with round numbers (bytes over the network, CPU time on a cheap phone, memory in a long-lived tab, and requests per second at the server) that turns "this might be slow" into a reason to pick one design over another.
- HTTP/1.1, HTTP/2, and HTTP/3The same requests carried three ways (one at a time per TCP connection, many at once on one TCP connection, or many at once on QUIC over UDP), which decides bundling, sprites, sharding, and how a lossy network feels.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.