How Gzip Makes the Web Feel Faster: Inside HTTP Compression

[copy 19 bytes, back 19]
[copy 19 bytes, back 19]
[copy 19 bytes, back 19]

Multiply that across a full HTML document with repeated tags, repeated class names, repeated whitespace, and it’s easy to see why text shrinks so dramatically. Gzip stacks a second trick on top: Huffman coding, which gives the most common bytes shorter codes — the same idea as Morse code giving “E” a single dot because it shows up constantly in English.

Byte frequency in a typical HTML file:

'<' ████████████████ very common → short code (e.g. 3 bits)
' ' ██████████████ very common → short code (e.g. 3 bits)
'e' ██████████ common → medium code (e.g. 5 bits)
'z' █ rare → long code (e.g. 10 bits)

Repeated stuff is cheap. Unique stuff is expensive. That’s really the whole idea, and it’s a satisfying one once it clicks. HTML, CSS, JSON, and JavaScript are full of repetition, so they compress hard — usually 60 to 80 percent smaller.

Images are a different story. JPEG, PNG, and WebP already squeeze the redundancy out of the picture itself before the file is even saved to disk — that’s what makes them JPEG or PNG in the first place. By the time gzip gets to one of these files, there’s barely any repetition left to find. It’s like trying to pack a suitcase that’s already vacuum-sealed. Most servers just skip compressing images entirely and save the CPU.

Step 4: which algorithm actually wins

For a long time gzip was the only real option, and honestly it’s still fine. But two newer formats have mostly taken over for anything that cares about performance:

Gzip Brotli Zstandard Support basically everywhere nearly everywhere now newer, but growing fast Best at safe universal fallback pre-built static assets (your JS/CSS bundles) fast, high-volume dynamic responses CPU cost low to moderate can be heavy at max quality very cheap for what it gives you

Most production setups follow a simple ladder: try Brotli first if both sides support it, fall back to gzip, and if neither is supported, send the plain response. Nobody hand-picks the algorithm per request — the server works it out from the Accept-Encoding header on the fly.

That’s the entire decision tree a server walks through on every request. Nothing fancier than checking what the client claimed it could handle, in order of preference, and picking the first match.

The table above is accurate, but it’s worth seeing the actual trade-off as a shape rather than words. Roughly, for the same piece of text:

Output size after compression (smaller is better):

Gzip ████████████████████████░░░░░░ ~70% of original
Brotli ████████████████████░░░░░░░░░░ ~65% of original
Zstandard ████████████████████░░░░░░░░░░ ~66% of original
CPU cost to produce that output (smaller is better):
Gzip ████████░░░░░░░░░░░░░░░░░░░░░░ low
Brotli ████████████████████████░░░░░░ high at max quality
Zstandard ██████░░░░░░░░░░░░░░░░░░░░░░░░ very low

Brotli usually wins on size, but it earns that by spending more CPU to find better compression opportunities. Zstandard’s whole pitch is the opposite trade: give up a little bit of size to get compression that’s cheap enough to run on every request without blinking. That’s why Brotli tends to win for static assets you compress once, and Zstandard is gaining ground for dynamic, high-traffic APIs.

Step 5: it costs CPU, so don’t max it out

Here’s a mistake I’ve actually made myself: cranking compression to the highest setting because smaller has to be better. It isn’t, not automatically. Every algorithm has a level dial — gzip runs 1 through 9, Brotli goes 0 through 11 — and the jump from level 6 to level 9 shaves off a little more size while costing a lot more CPU.

Compression level →   1 ─────── 6 ─────────── 9
File size saved → little good a little more
CPU time spent → little moderate a LOT more

For a static JS bundle you build once and cache forever, go ahead and max it out. You’re only paying that cost a single time. For a dynamic API response you’re generating fresh on every request, that math flips hard. Most production configs settle around gzip level 6 or Brotli quality 4–5 for exactly this reason.

The header everyone forgets, and the bug it causes

There’s one more header that quietly prevents a genuinely nasty bug:

Content-Encoding: br
Vary: Accept-Encoding

Picture a CDN sitting in front of your server. One visitor’s browser supports Brotli, gets a Brotli response, and the CDN caches it. A second visitor comes along whose browser only understands gzip. Without Vary: Accept-Encoding, the CDN has no idea it’s not allowed to hand that cached Brotli response to a client that can’t decode it — so it does anyway.

The second visitor’s browser gets a pile of bytes it can’t unpack, and the page shows up as garbled nonsense. It’s a one-line header, and it’s the difference between “compression saved us bandwidth” and “why is the site broken for some random subset of our users.” I didn’t even know this header existed until I got bitten by exactly this bug behind a CDN, and it took an embarrassingly long time to figure out why only some visitors saw a broken page.

What this actually saves you

Say an API returns 100 KB of JSON and gets hit a million times a day. Uncompressed, that’s 100 GB moving across the wire every day. Gzip it down to roughly 35 KB per response and you’re at 35 GB.

Daily bandwidth for one endpoint, 1M requests/day:

Uncompressed ██████████████████████████████████████████ 100 GB
Compressed ███████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 35 GB

65 GB saved, every day

That’s 65 GB saved, on one endpoint, every day. Multiply that across every page, every API call, every stylesheet a real site serves, and a handful of config lines end up being a meaningful chunk of a company’s actual cloud bill — not just a nice-to-have for page speed scores.

How does the browser know where the response ends?

Small detail, but people ask this a lot once they start digging. Every HTTP response usually carries a Content-Length header, and once compression kicks in, that number changes to match the compressed size, not the original one:

Content-Length: 32000

Not 120000. The browser reads that number, downloads exactly that many bytes, decompresses them, and knows it has the whole thing.

There’s a second way servers handle this, especially for APIs where the final size isn’t known upfront — think a response being streamed out piece by piece. Instead of Content-Length, you’ll see:

Transfer-Encoding: chunked

That tells the browser “I don’t know the total size yet, I’ll send it in chunks and mark the end when I’m done.” You’ll run into this a lot on HTTP/1.1 APIs, and it works fine alongside compression too — the two aren’t in conflict, they just answer different questions.

The double-compression trap

Here’s one that’s bitten more teams than you’d think, including ones with solid engineers. Say your Express app compresses its own responses with the compression middleware. Then Nginx sits in front of it with gzip on in the config, doing the exact same job again.

Express (compress())  →  Nginx (gzip on)  →  Browser
│ │
compresses once tries to compress
with gzip an already-gzipped
body — wasted CPU,
or a broken response

Now you’re either compressing an already-compressed response a second time — which does almost nothing and just burns CPU on both layers — or worse, Nginx tries to gzip something that’s already gzipped and the browser ends up with a mess it can’t decode cleanly. The fix is boring: pick exactly one layer to own compression, usually the reverse proxy or CDN since it’s closer to the network, and turn it off everywhere else in the chain.

One security note: BREACH

This rarely makes it into beginner tutorials, but it’s worth knowing it exists. Back in 2013, researchers found an attack called BREACH that uses compression itself as a side channel — even over HTTPS. If a page reflects back something the attacker controls (like a search term) and a secret (like a CSRF token) in the same compressed response, an attacker can guess the secret one byte at a time. Guesses that partly match the real secret compress slightly smaller than ones that don’t, and that size difference leaks through, even encrypted.

You don’t need to memorize the mechanics. Just know the rule: don’t mix user-controlled input and secrets in the same compressed response, and if you’re not sure, that one endpoint is a fine place to just turn compression off.

The quick mistakes list

A few things worth double-checking if you’re setting this up yourself:

  • Don’t compress images, videos, or fonts that are already compressed — you’ll waste CPU for basically nothing.
  • Don’t max out the compression level for dynamic, per-request responses. Save max quality for stuff you build once, like static bundles.
  • Don’t forget Vary: Accept-Encoding if there’s a CDN or any shared cache in front of your server.
  • Set a minimum size threshold, roughly 1 KB. Compressing a tiny 200-byte response can actually make it bigger once you add the compression overhead.
  • Pick exactly one layer in your stack to own compression. If your app and your reverse proxy are both compressing, turn one of them off.
  • Don’t reflect secrets and user-controlled input in the same compressed response — that’s the BREACH pattern from above.

Putting the whole request back together

That’s every piece. It’s worth seeing them in one place, because none of these steps is complicated on its own — the trick is that they all happen automatically, in order, without your application ever knowing:

Your browser asks what it can handle. Something in front of your app — never the app itself — picks an algorithm and squeezes the response. The response comes back labeled with exactly what was done to it, so decompression on the browser side is never a guess. Five steps, all of them boring, all of them the reason your Network tab shows a smaller number than the page actually contains.

The part users never see

None of this is visible to anyone using your site. Nobody’s ever going to open a support ticket that says “thanks for the Vary header.” They just notice the page loaded fast and move on with their day. Which might be the actual point of most good infrastructure work — it disappears completely the moment it’s done right, and the only proof it exists is a number in a Network tab that most people will never think to check.

Similar Posts

Leave a Reply