writing

gzip vs brotli — what compression really does to your bundle

Compression shrinks what crosses the wire, not what the browser has to parse. How gzip and brotli work, which one to serve, and why you should track the compressed number — not the raw one.

Almost every byte of JavaScript your users download is compressed in transit. That single fact quietly reshapes how you should think about bundle size: the number that lands on the network is not your dist/ file size — it is that file after gzip or brotli. If you are budgeting against the raw number, you are budgeting against a figure no user ever downloads.

Here is what compression actually does, where its limits are, and which number to watch.

Two different sizes, two different costs

A bundle has (at least) two sizes that matter, and they cost the user in different places:

  • Transfer size — bytes on the wire, after compression. This drives download time, and it is what a < 150kb style budget should mean.
  • Parse/compile/execute size — the browser decompresses the file first, then parses and runs the uncompressed JavaScript. Compression does nothing for this. 300kb of raw JS is 300kb of parse work whether it arrived as 90kb of brotli or not.

Compression is a transfer optimisation. It buys back download time on slow connections; it does not buy back main-thread time. That is why shipping less code still wins even when compression is doing its job — the parse/execute cost is the part that degrades INP and LCP on mid-range phones.

How gzip and brotli actually shrink code

Both are dictionary compressors: they replace repeated byte sequences with short back-references. JavaScript compresses extremely well because it is so repetitive — every function, return, const, property name, and framework idiom recurs hundreds of times. A typical minified bundle drops to 25–35% of its raw size under gzip.

brotli (Content-Encoding: br) improves on gzip in two ways that suit the web: a larger sliding window, and a built-in static dictionary seeded with common HTML, CSS, and JS tokens. On real bundles brotli at max quality lands roughly 15–25% smaller than gzip — often the difference between a page that squeaks under a budget and one that blows it.

You can see the gap yourself without deploying anything:

# raw, gzipped, and brotli sizes of one file
BUNDLE=dist/main.[hash].js
echo "raw:    $(wc -c < "$BUNDLE")"
echo "gzip:   $(gzip -9 -c "$BUNDLE" | wc -c)"
echo "brotli: $(brotli -q 11 -c "$BUNDLE" | wc -c)"

The -9 / -q 11 flags ask for maximum compression. That matters more than it looks — see below.

Static vs dynamic compression (the mistake that costs you brotli)

There are two moments you can compress a file:

  • Dynamically, per request, in the CDN or server. To stay fast, dynamic compression uses a low quality level — often brotli quality 4–5, barely better than gzip. Great for HTML that changes every request.
  • Statically, once at build time, writing main.js.br next to main.js. Now you can afford brotli quality 11, because it runs once instead of on every request.

Hashed, immutable build assets are the ideal case for static compression: they never change, so paying the one-time cost of max-quality brotli is free at runtime. Most bundlers emit the precompressed files for you:

// vite.config.ts
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
  plugins: [
    viteCompression({ algorithm: 'brotliCompress', ext: '.br' }),
    viteCompression({ algorithm: 'gzip', ext: '.gz' }), // fallback
  ],
});

Then make sure your server or CDN actually serves the .br file when the request carries Accept-Encoding: br. Emitting the file but not serving it is a surprisingly common own-goal.

Verify what the browser really receives

Do not assume — confirm the response is compressed end to end:

curl -sI -H 'Accept-Encoding: br, gzip' https://your-site.com/main.[hash].js \
  | grep -i -E 'content-encoding|content-length'

You want content-encoding: br (or gzip) and a content-length close to your static compressed size. If content-encoding is missing, something between you and the user is stripping or re-inflating it — proxies and misconfigured CDNs both do this.

Minification still matters under compression

A tempting but wrong conclusion: "the compressor finds the repetition, so minifying is redundant." It is not. Minification and compression stack, because they remove different things:

  • Minification deletes whitespace and shortens identifiersuserAccountBalance becomes a. A short token is cheaper to encode and recurs more, which feeds the compressor.
  • Tree-shaking removes dead code the compressor would otherwise faithfully compress. The cheapest byte to transfer is the one you never ship.

Minify first, compress second. The two are multiplicative, not redundant.

Track the compressed number over time

Because compression is where the real transfer cost lives, the compressed (gzip) size is the number worth trending — a raw-size chart can stay flat while a new dependency quietly wrecks your gzip ratio (think: a pre-minified vendor blob that compresses poorly). dendrobundle records gzipped size per snapshot, so you watch the figure your users actually pay for, commit over commit.

Set your CI budget against the compressed size, precompress your immutable assets with max-quality brotli, and confirm the header lands. Then spend the rest of your effort on the size compression can't fix — shipping less JavaScript in the first place. Drop your stats into the free analyzer to see today's compressed total.