cf.completefrontend
My progress
FUNDAMENTALS · MODULE 8

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.

A bar chart, to scale, of React DOM's client file from React 19.1 at five stages. Development build: 928 KB. Production build, not minified: 503 KB. Minified with Terser: 242 KB. Minified and gzip level 6: 61 KB. Minified and Brotli level 11: 51 KB.react-dom-client, bytes to scaledevelopment build928 KBproduction build, not minified503 KBminified with Terser242 KBminified + gzip 661 KBminified + Brotli 1151 KBtransfer size is what the network carries; parsed size stays 242 KB
Measured on one real file. Removing development checks halves it, minification halves it again, and compression takes what is left down to about a fifth. The browser still parses the 242 KB.

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

Minification and compression side by side.
QuestionMinificationCompression
What it changesThe source text itself: a new, shorter programOnly the encoding of the bytes for the trip
Is the output still runnable code?Yes, the browser runs it directlyNo, it must be decoded first
Reversible?No: comments and original names are gone (source maps map back)Yes, exactly, byte for byte
Which filesText the browser parses: JS, CSS, HTML, SVG, JSONAny response, but only text gains much
When it runsOnce, at build timeAt build time (static) or per request (dynamic)
Who does itYour bundler: Vite, webpack, Next.js, esbuildYour server or CDN, steered by HTTP headers
Typical saving on a JS bundle40% to 60% of the raw size70% 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.

A stack of three layers. At build time, once: source files of 503 KB go through the minifier, which gives 242 KB of runnable code, then precompression writes a 51 KB Brotli file and a 61 KB gzip file. At request time: the CDN picks an encoding for each request and sends Content-Encoding br. In the browser: it decodes the body and parses 242 KB of minified code.Build time: once per deployEvery requestBrowserSource files503 KB, readableMinifier242 KB, runnablePrecompress.br 51 KB, .gz 61 KBCDN picks an encodingContent-Encoding: brDecode, then parse242 KB parsed
Minification happens once and changes the program. Compression is undone in the browser, so the parser sees the minified size, not the transfer size.

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?

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.

js
// 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}
The same function before and after minification. Byte counts measured with Terser 5 for the code shown, without the two label comments. Note the loop variable reuses the letter o: inside the loop it shadows the parameter, which is legal and safe.
Before and after minification. Before, 305 bytes: a doc comment, the function totalWithTax with parameters cart and taxRate, a constant DEBUG set to false, a subtotal variable, a loop over cart.items, an if block on DEBUG, and a return. The comment, the DEBUG constant, and the if block are removed; cart, taxRate, subtotal, and item are renamed. After, 100 bytes: function totalWithTax(t,o){let e=0;for(const o of t.items)e+=o.price*o.quantity;return e+e*o}.before: 305 bytesafter: 100 bytes/** Adds tax... */function totalWithTax( cart, taxRate) { const DEBUG = false; let subtotal = 0; for (const item of if (DEBUG) {...} return subtotal...function totalWithTax(t,o){let e=0;for(const o of t.items)e+=o.price*o.quantity;return e+e*o}removedrenamedoutput
The exported name 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

  1. 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.
  2. Renames local names (mangling). subtotal becomes e because nothing outside the function can refer to it. Exported names, globals, and object properties like item.price stay as they are, because other code reaches them by name.
  3. Removes dead code. DEBUG is a constant false, so the if block can never run and is removed, along with the now unused constant. This is the step that strips development-only warnings from libraries.
  4. Rewrites into shorter equivalents. true becomes !0, undefined becomes void 0, an if with one return becomes a ternary, consecutive var lines merge, and constant expressions like 60 * 60 * 1000 fold into 36e5.
  5. 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

Common minifiers in 2025-2026. Speeds vary by project; the pattern is that native tools are much faster for similar output.
ToolWritten inWhere you meet itTrade-off
TerserJavaScriptwebpack production mode default; Vite and Rollup optionVery thorough output, the slowest of these on big bundles
esbuildGoVite's default up to Vite 7; many toolsMany times faster; output usually within a few percent of Terser
Oxc minifierRustVite 8 default, through RolldownVery fast, output close to Terser
SWCRustNext.js default minifier; Parcel; RspackFast, and close to Terser in size
cssnanoJavaScript (PostCSS)PostCSS pipelines; webpack's CSS minimizer pluginMany safe CSS rewrites, slower
Lightning CSSRustParcel; Vite 8's default CSS minifierVery 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

The text item.id;item.name;item.id, 25 characters, encoded step by step. The first 8 characters go out as literals. item. is a repeat from 8 characters back and becomes a pointer: back 8, copy 5. name; goes out as 5 literals. item.id is a repeat from 18 characters back and becomes a pointer: back 18, copy 7. The result is 13 literals and 2 pointers.input, one character per box0123456789101112131415161718192021222324item.id;item.name;item.idoutput0 of 25 characters encoded
The compressor reads 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.

The three HTTP content encodings you will meet in 2025-2026.
QuestiongzipBrotliZstandard
Token in HTTP headersgzipbrzstd
SpecRFC 1952 (DEFLATE: RFC 1951)RFC 7932RFC 8878 (HTTP limits: RFC 9659)
Window32 KBUp to 16 MBUp to 8 MB when used over HTTP
Built-in dictionaryNoneAbout 120 KB of web textNone built in
Levels1 to 9 (default 6)0 to 11Negative fast levels, 1 to 19 (to 22 with extra memory)
Browser supportEvery clientAll current browsers, HTTPS onlyChrome and Edge 123+, Firefox 126+, Safari 26.3+
Best useUniversal fallbackStatic text assets at level 11; dynamic at 4 to 6Dynamic 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.

http
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
A request that lists the encodings the browser can decode, and a response that says which one it used.

The three headers, one by one

  • Accept-Encoding (request): the encodings this client can decode. Chrome on HTTPS sends gzip, deflate, br, zstd. On plain HTTP, browsers leave out br and zstd. Command line tools often send none at all: curl asks 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 calls identity.
  • Vary: Accept-Encoding (response): tells every cache between server and browser that this URL has different bodies depending on the request's Accept-Encoding. A cache must store them separately and hand each client a variant it can decode.
A sequence diagram between the browser, a CDN edge, and the origin. The browser asks for app.js with Accept-Encoding br, gzip. The CDN has no copy and forwards the request. The origin answers 200 with Content-Encoding br and Vary Accept-Encoding. The CDN passes the 51 KB Brotli body to the browser and stores it under the URL plus the encoding. A later request that also accepts br is answered from the edge.BrowserCDN edgeOriginAccept-Encoding: br, gzipmiss: forwardContent-Encoding: brbr body, 51 KBCDN stores it under: /app.js + brVary: Accept-Encoding keeps br and gzip aparta gzip-only client gets its own copyAccept-Encoding: br, gzipedge hit: br, 51 KB
The browser offers, the server chooses, and 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?

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.

Measured with Node 22 zlib on the minified React DOM client file (247,655 bytes). KB here means 1,024 bytes.
SettingOutput sizeVersus minifiedCompress timeDecompress time
Minified, no compression242 KB100%0 ms0 ms
gzip level 171 KB29%1.5 ms0.5 ms
gzip level 6 (default)61 KB25%3.8 ms0.5 ms
gzip level 961 KB25%4.6 ms0.5 ms
Brotli level 557 KB23%3.5 ms0.5 ms
Brotli level 955 KB23%6.7 ms0.5 ms
Brotli level 11 (max)51 KB21%269 ms0.6 ms
Zstandard level 3 (default)63 KB26%0.8 ms0.3 ms
Zstandard level 1954 KB22%54 ms0.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.

A plot of compressed size against compression time on a log scale, for the 242 KB minified React DOM file. Zstandard 3: 63 KB in 0.8 ms. gzip 6: 61 KB in 3.8 ms. Brotli 5: 57 KB in 3.5 ms. Zstandard 19: 54 KB in 54 ms. Brotli 11: 51 KB in 269 ms.biggersmaller1 ms10 ms100 mstime to compress, log scaleZstandard 3: 63 KB, 0.8 msgzip 6: 61 KB, 3.8 msBrotli 5: 57 KB, 3.5 msZstandard 19: 54 KB, 54 msBrotli 11: 51 KB, 269 ms
Lower is smaller, further right is slower. Brotli 5 beats gzip 6 at the same speed, which suits per-request compression. Brotli 11 is the smallest but about 70 times slower, which is fine only when you compress once at build time. gzip 9 sits on top of gzip 6, so it is left out.

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?

Precompress at build time

Build writes .br (level 11) and .gz (level 9) next to each hashed asset

Server or CDN serves the matching file based on Accept-Encoding

Pros

  • Smallest files, about 15% to 20% below gzip 6
  • Zero CPU per request

Cons

  • Only works for files known at build time
  • Needs server or CDN support for precompressed files

Compress at the CDN edge

Origin sends plain responses; the CDN compresses and caches each encoding

Often a single setting in the CDN dashboard

Pros

  • No work in your build or app server
  • Covers dynamic HTML and API responses too

Cons

  • You don't control the level; dynamic levels are usually moderate
  • Uncached dynamic responses pay compression time on each miss

Compress in the app server

Middleware like Express compression encodes each response before sending

Pros

  • Works anywhere, including local and internal tools
  • Full control over level and types

Cons

  • Uses the same CPU that renders pages
  • Easy to break streaming or double-compress behind a proxy

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.

Asset:
Algorithm:
Level:

React DOM's client file from React 19.1. Minified with Terser.

JS bundle: original 503 KB, minified 242 KB, on the wire, Brotli 11 51 KB.original503 KBminified242 KBon the wire, Brotli 1151 KB
Bars are to scale for the chosen asset. Sizes and times were measured with Node's built-in zlib on one laptop; your server will differ, but the pattern holds.

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.

A flowchart for one response. Is it already compressed, like an image, a WOFF2 font, or video? If yes, send it as is. If no: is it under about 1 KB? If yes, send it as is. If no: is it the same bytes for every user, like a hashed JS, CSS, or SVG file? If yes, precompress it at build time with Brotli 11 and gzip 9. If no, compress it per request with Brotli 4 to 5, Zstandard 3, or gzip 6.Already compressed?images, WOFF2 fonts, videoyesSend as isno gain, wasted CPUnoUnder about 1 KB?a short JSON replyyesSend as isfits in one packetnoSame bytes for all?hashed JS, CSS, SVGyesPrecompressBrotli 11 + gzip 9noCompress per requestBrotli 4-5, zstd 3, gzip 6
Yes goes right, no goes down. One more check for dynamic pages: if a response mixes a secret with text the user controls, mask the secret or skip compression for that response.

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 SameSite cookies, 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

  1. First visit. The server sends app.3f9a1c.js as 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.
  2. 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: dcb and dcz in Accept-Encoding, and Available-Dictionary with the SHA-256 hash of the stored file.
  3. 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 with Content-Encoding: dcz and Vary: Accept-Encoding, Available-Dictionary.
  4. 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 br or gzip, so nothing breaks.
A sequence diagram between the browser and the server over two visits. Visit one: the browser gets app.v1.js with Content-Encoding br and a Use-As-Dictionary header, and keeps v1 as a dictionary. Visit two: the browser asks for app.v2.js and sends Available-Dictionary with the hash of v1. The server answers with Content-Encoding dcz, 3.5 KB instead of 52 KB, and the browser rebuilds v2 using v1. Browsers without support get plain Brotli, 52 KB.BrowserServerGET app.v1.js200, br, Use-As-Dictionarybrowser keeps v1 as a dictionaryGET app.v2.js + Available-Dictionary200, dcz: 3.5 KB, not 52 KBdecodes v2 using v1no support: plain br, 52 KB
The old version on disk becomes the dictionary for the new one. Sizes are measured on a real React DOM upgrade from 19.1 to a 19.2 build.

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?

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-Encoding header.
  • 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 encodedBodySize and decodedBodySize for 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-limit report Brotli size by default and fail the build past the budget, as the performance budgets lesson shows.
  • curl with a real header. curl -sI -H "Accept-Encoding: br, gzip" URL shows exactly what the server negotiates, from any network you choose.
A sketch of the DevTools Network panel with four rows. app.3f9a.js, script: 52.3 kB transferred, 248 kB resource. styles.c1d2.css, stylesheet: 5.2 kB transferred, 37.4 kB resource. api/products, fetch: 2.2 kB transferred, 34.2 kB resource. hero.avif, image: 88.0 kB transferred, 87.7 kB resource. The footer reads 4 requests, 148 kB transferred, 407 kB resources.DevTools: NetworkNameTypeTransferredResourceapp.3f9a.jsscript52.3 kB248 kBstyles.c1d2.cssstylesheet5.2 kB37.4 kBapi/productsfetch2.2 kB34.2 kBhero.avifimage88.0 kB87.7 kB4 requests | 148 kB transferred | 407 kB resourcestransferred: what the network carriedresource: what the browser decoded and parses
Text files show a big gap between the two columns; the image doesn't, because it was already compressed and headers add a little. DevTools counts kB as 1,000 bytes.

What breaks, how you notice, how you fix it

Common compression failures in production.
FailureHow you noticeFix
Some files go out uncompressedTransferred equals resource size; no Content-EncodingCheck the server's content type allow list; a JS file served as application/octet-stream is skipped
Development build shippedBundle several times the expected size; console shows dev warningsSet the production mode or NODE_ENV=production in the build
Missing Vary: Accept-EncodingRare clients get unreadable files from a shared cacheSend Vary on every compressed response; let the CDN normalize the header
Double compressionScript fails to parse or the browser reports a decoding error; the body was encoded twiceCompress in exactly one layer; disable it in the app when a proxy or CDN does it
Brotli only at the edgeEdge hits are small, origin misses are bigPrecompress at build so origin responses are already Brotli
Streaming HTML arrives all at onceWaterfall shows one late HTML chunkFlush the compressor on each chunk, or move compression to a layer that streams
Wrong Content-LengthTruncated files or hung requestsLet 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.
An interview whiteboard. Top row, left to right: CI build, which minifies and writes .br and .gz files; storage with hashed files; a CDN edge that picks br or gzip; and the browser, which decodes and parses. Below storage, an SSR server sends HTML and JSON to the CDN, which compresses them with Brotli 5. A note lists the rules: static assets Brotli 11 at build with gzip fallback, dynamic Brotli 5 or Zstandard 3 at the edge, media never recompressed, budgets measured in Brotli bytes.CI buildminify + .br .gzStoragehashed filesCDN edgepicks br or gzipBrowserdecodes, parsesstatic path: compressed onceSSR serverHTML and JSONdynamic: Brotli 5static: Brotli 11 at build, gzip fallbackdynamic: Brotli 5 or zstd 3 at the edgemedia: never recompressedbudget: measured in Brotli bytes
Two paths, two compression points: static files are packed once at build, dynamic responses are packed at the edge. Mention the budget unit, and you have covered it in under a minute.

What to say, by level

LevelWhat a strong answer includes
JuniorMinify JS and CSS in the production build; serve text with gzip or Brotli; images and fonts are already compressed; know raw versus transfer size
MidBrotli 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
SeniorWhere 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, and Vary: Accept-Encoding. A missing Vary or 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 (encodedBodySize versus decodedBodySize), 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?

Question 2

gzip or Brotli? And what level?

Question 3

Walk me through how the browser and server agree on compression.

Question 4

Should we gzip our images and fonts too?

Question 5

Our app deploys ten times a day and returning users keep downloading the full bundle. What can we do?

Practice it

These design problems lean on compression and minification.

References

Read next