DEV Community

Cover image for How Gzip Makes the Web Feel Faster: Inside HTTP Compression
Tanzim Hossain
Tanzim Hossain

Posted on

How Gzip Makes the Web Feel Faster: Inside HTTP Compression

Last week I was debugging a slow API and, out of habit, popped open Chrome DevTools and clicked the Network tab. Size and Transferred show up as two separate columns for every request there, and it's easy to skim past a hundred times without noticing. This time I actually looked.

Size: 120 KB. Transferred: 32 KB.

My first thought was: did the browser skip loading part of the page? It hadn't. Every div was in place, nothing missing. So where did the other 88 KB go?

Nothing got lost. The server packed the response into a smaller box before shipping it, and the browser unpacked it the moment it landed. That's the whole trick behind why modern websites feel fast even when the page itself is huge. It's called HTTP compression, and once you see how it works, you can't un-see it in every Network tab you'll ever open again.

Let's walk through it the way a request actually travels.

Step 1: your browser announces what it can unwrap

Before your server sends back a single byte, your browser already told it something important, tucked into a header you've probably scrolled past a hundred times:

GET /index.html HTTP/1.1
Host: example.com
Accept-Encoding: gzip, br, zstd
Enter fullscreen mode Exit fullscreen mode

That Accept-Encoding line is basically your browser leaning over and saying, "if you compress this, I know how to open it back up — gzip, Brotli, or Zstd, your call." This matters more than it looks. A server should never send a compressed file the client can't decompress. If the browser doesn't list an algorithm, the server just sends the plain, uncompressed version. No negotiation, no compression.

Here's that negotiation as a sequence, since it's really just two messages:

One request, one response, one header on each side. The browser proposes what it can handle, the server picks from that list, and it tells the browser exactly which one it used so decompression is unambiguous. No guessing on either end.

Step 2: your app has no idea any of this is happening

Here's the part that surprised me the first time I actually thought about it: your Express route, your NestJS controller, your Django view — none of it knows compression exists. It builds a normal response. A string of HTML, a JSON object. That's it. No .gz file, no compress() call, no awareness that any of this is going on.

Compression doesn't happen inside your application. It happens around it, usually a couple of layers away from any code you'll ever touch:

Browser
   │
   │  Accept-Encoding: gzip, br, zstd
   ▼
CDN / Reverse Proxy   ◄── compression usually happens here
   │
   │  (forwards the request, uncompressed)
   ▼
Your App (Express, Django, NestJS...)
   │
   │  returns a plain response — no idea compression exists
   ▼
CDN / Reverse Proxy   ◄── squeezes the response before sending it back
   │
   │  Content-Encoding: br
   ▼
Browser
Enter fullscreen mode Exit fullscreen mode

Any layer between your app and the browser could be the one doing the actual squeezing — Nginx sitting in front of your app, Express's compression middleware, or Cloudflare doing it at the edge before the request even reaches your origin. Doesn't matter which. Your application code stays exactly the same either way. It builds a response and hands it off; something else decides whether to shrink it.

Step 3: why does squeezing text even work?

This is the part that clicked for me once I stopped thinking of it as magic. Look at this chunk of HTML:

<div class="card">
<div class="card">
<div class="card">
<div class="card">
Enter fullscreen mode Exit fullscreen mode

Same 18-ish characters, four times in a row. Instead of storing that phrase four separate times, a compression algorithm stores it once and leaves a note that says "copy the thing you saw a few lines up, again, right here." Here's roughly what that looks like once LZ77 gets through it:

BEFORE (36 bytes):
<div class="card">
<div class="card">
<div class="card">
<div class="card">

AFTER LZ77 (conceptually):
<div class="card">
[copy 19 bytes, back 19]
[copy 19 bytes, back 19]
[copy 19 bytes, back 19]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)