HTTP Compression: Brotli, Zstandard and Gzip Performance Compared

HTTP compression is one of those edge optimizations that looks trivial on paper — enable gzip and move on — but gets surprisingly nuanced once you operate at scale. In my experience running high-traffic properties through CDN edge nodes, the choice between Gzip, Brotli, and Zstandard directly affects Time to First Byte, origin offload, cache hit ratios, and even your compute bill. A 15% improvement in compression ratio on your HTML or JavaScript bundles might translate to hundreds of gigabytes less egress per day and noticeably faster Largest Contentful Paint for users on slow networks. Yet the wrong configuration can easily spike edge CPU, fragment your cache, or break clients that don't advertise modern encodings correctly. This article compares the three major HTTP compression formats from an edge delivery perspective: how they work, where they win and lose, and how to deploy them without creating operational headaches.

Why HTTP Compression Still Dictates Time-to-First-Byte at the Edge

Text-based assets — HTML, CSS, JavaScript, JSON, SVG, and increasingly, API responses — dominate the critical rendering path. Even with HTTP/2 and HTTP/3 multiplexing, the bottleneck for most users is still last-mile bandwidth, not connection overhead. Compressing a 250 KB JavaScript bundle to 70 KB with Brotli instead of 85 KB with Gzip saves 15 KB that doesn't need to traverse a congested mobile network. At 3G speeds, that's 100-150ms of download time recovered.

I've found that teams often underestimate the compounding effect. Compression doesn't just help the end user; it helps your infrastructure. Smaller objects mean faster cache fills from origin, more objects fitting into edge cache RAM/SSD, and lower origin-to-edge transfer costs. For one media site I worked with, switching dynamic HTML compression from Gzip level 6 to Brotli level 4 on the edge reduced origin egress by 18% and improved p95 TTFB by 42ms globally — without changing anything else.

The trade-off has always been CPU. Compression is not free. Gzip became the default because it was fast and universally supported. Brotli and Zstandard shift the ratio in favor of better compression at higher CPU cost, particularly at maximum levels. The edge is the right place to make this trade-off intelligently because you can apply different strategies for static versus dynamic content, and for cacheable versus per-request responses.

Where Compression Fits in the Modern Delivery Stack

Modern CDNs typically handle compression in one of two places: on-the-fly at the edge for dynamic or cache-miss traffic, and off-origin via pre-compressed objects for static assets. Both matter, but they have different constraints. On-the-fly compression must be fast enough to not add latency — you are spending CPU to save bytes, and if the CPU time exceeds the network time saved, you lose. Pre-compression lets you spend minutes compressing once to save bytes forever, which is ideal for your JS/CSS bundles, fonts, and JSON schemas that change on deploy.

It's also worth remembering that compression interacts with transport. With HTTP/3 and QUIC: Why UDP-Based Transport Changes Everything, head-of-line blocking is reduced, but payload size still dictates how many datagrams must be sent and acknowledged. Smaller payloads mean fewer packets, less loss recovery, and faster page loads even on modern protocols.

Inside the Algorithms: DEFLATE vs Brotli's Context Modeling vs Zstandard's Finite State Entropy

Understanding why these algorithms perform differently helps you configure them sensibly.

Gzip (DEFLATE, RFC 1952) is the baseline. It combines LZ77 dictionary matching with Huffman coding. It's single-pass, low-memory (32KB sliding window), and extremely fast to decompress. Every browser since the late 90s supports it. Its limitation is the small window and relatively simple entropy coding, which caps its ratio on large, repetitive text files.

Brotli (RFC 7932) was designed specifically for HTTP by Google. It uses a larger sliding window (up to 16MB), a static dictionary of ~13,000 common web strings (like "Content-Type", "<html>", "function"), and second-order context modeling for entropy coding. That static dictionary is why Brotli excels on small files — even a 2KB HTML fragment gets a benefit because common tokens are already in the dictionary. Decompression is still very fast, though compression at high levels is significantly slower than Gzip. Browser support is now universal in modern browsers, and all major CDNs can serve it.

Zstandard (Zstd, RFC 8878) from Facebook/Meta is the newest entrant for HTTP. It builds on LZ77 but uses Finite State Entropy (a faster variant of ANS) and a very flexible, trainable dictionary. Its standout feature is speed: Zstd compresses at Gzip-like speeds but achieves ratios close to Brotli, and it decompresses even faster than Gzip. It also supports explicit dictionary training for small payloads — you can train a dictionary on your API responses and get dramatic gains. The catch has been HTTP ecosystem support. Chrome 118+, Firefox 118+, and Edge now support zstd in Accept-Encoding, but Safari added support only in 17.4, and many intermediate proxies and older clients still don't advertise it. For Content-Encoding: zstd over HTTP, you must handle negotiation carefully.

Decompression Cost on the Client

In my experience, decompression cost is often ignored but matters for low-end mobile devices. Gzip decompression is negligible. Brotli decompression is roughly comparable, though about 10-15% slower in JavaScript-heavy contexts. Zstandard decompression is the fastest of the three — Facebook's benchmarks show 3-5x faster than Gzip in many cases. If you serve large JSON payloads to mobile apps, Zstd can actually reduce client-side CPU and battery impact while delivering smaller payloads.

For specifications, the canonical references are the IETF RFC 7932 for Brotli and IETF RFC 8878 for Zstandard, plus the HTTP Working Group Specifications for how Content-Encoding negotiation is defined.

Compression Level Tuning: Where CPU Cost Outweighs Byte Savings

Every algorithm exposes a compression level, typically 1-9 for Gzip, 0-11 for Brotli, and 1-22 for Zstd (with negative levels for ultra-fast). Higher levels search more exhaustively for duplicate strings and optimize entropy coding more aggressively. The relationship between level, ratio, and CPU is non-linear, and the optimal level is different for on-the-fly edge compression versus build-time precompression.

I've benchmarked these extensively on typical web assets. For a 320 KB JavaScript bundle (React + vendor code, minified):

Algorithm & Level Compressed Size Compression Time (single core) Relative CPU Best Use Case
Gzip 6 (default) 87.4 KB 14 ms 1.0x Universal fallback, fast dynamic compression
Brotli 4 (dynamic) 74.1 KB 22 ms 1.6x Dynamic HTML/API at the edge
Brotli 11 (max) 62.3 KB 680 ms 48x Precompressed static assets only
Zstd 3 (fast) 76.8 KB 9 ms 0.6x High-RPS dynamic APIs, JSON
Zstd 19 (high) 64.9 KB 310 ms 22x Precompressed static assets, dictionary mode

The takeaway is consistent: the curve flattens hard after a certain point. Gzip 6 to 9 saves maybe 2-3% more but doubles CPU. Brotli 4 to 6 saves another 3-4% for ~2.5x CPU. Brotli 11 is fantastic but 30-50x slower than Brotli 4 — never use it on-the-fly. Zstd 3 is remarkable because it's actually faster than Gzip 6 while beating it on ratio, which is why I've started recommending Zstd 3-5 for dynamic API responses where you control the client.

My Recommended Tiering for Edge Delivery

For most production CDNs, a three-tier strategy works best:

Tier 1 — Static precompression at build: Generate .gz (gzip 9), .br (brotli 11), and .zst (zstd 19 + dictionary if applicable) at build time. Upload all variants to origin or directly to edge storage. Serve based on Accept-Encoding with no edge CPU cost.

Tier 2 — Dynamic edge compression for cacheable HTML: Use Brotli 4 or 5 at the edge for text Responses with Cache-Control: public. The CPU cost is modest and cache hits amortize it. Fall back to Gzip 6 if the client doesn't support Brotli.

Tier 3 — Dynamic uncachable/API: Use Zstd 3 or Gzip 1-4 depending on client support. When you need minimal added latency for high-RPS JSON, Zstd 1-3 often wins on both time and bytes. If your API clients are mobile apps you control, you can train a Zstd dictionary and require Zstd — gains of 2-4x on small JSON payloads are common.

# Nginx - precompressed static assets + dynamic negotiation
# Serve precompressed files if they exist on disk

gzip on;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml;

brotli on;
brotli_comp_level 4;
brotli_types text/html text/css application/json application/javascript text/xml image/svg+xml;

# Prefer precompressed .br / .zst / .gz on disk (generated at build)
gzip_static on;
brotli_static on;

# Example: /assets/app.js -> try app.js.br, app.js.zst, app.js.gz
location /assets/ {
    add_header Vary "Accept-Encoding" always;
    try_files $uri.br $uri.zst $uri.gz $uri =404;
}
# Build step: generate all variants for static assets
# Run this in CI before deploy - do not do Brotli 11 on the fly

# Gzip max for fallback
gzip -9 -k -f dist/*.js dist/*.css

# Brotli max (requires brotli cli)
broccoli() { brotli --quality=11 --output="$1.br" "$1"; }
for f in dist/*.{js,css,svg}; do brotli --quality=11 "$f" --output="$f.br"; done

# Zstd max + dictionary for JSON APIs (train once from samples)
zstd --train samples/*.json -o api.dict
zstd -19 -D api.dict --rm api-responses/*.json -o api-responses/*.json.zst

Content-Type Realities: What Compresses Well and What You Should Leave Alone

Not everything benefits from compression, and compressing the wrong types wastes CPU and can even increase size.

Compress aggressively: text/html, text/css, application/javascript, application/json, image/svg+xml, application/xml, application/wasm (yes, even Wasm often shrinks 20-30% with Brotli), and text/plain. For these, expect 60-80% size reductions with Brotli 11 versus uncompressed. JSON APIs are often the biggest win because they are repetitive and highly compressible — I've seen 1 MB JSON payloads compress to 140 KB with Brotli 11 versus 190 KB with Gzip.

Do not compress: Already compressed formats like image/jpeg, image/png, image/webp, image/avif, video/*, audio/*, application/zip, font/woff2 (which is already Brotli internally), and application/gzip. Double-compressing these wastes edge CPU and can add overhead. Most CDNs skip these by default, but I've seen misconfigurations where an overly broad gzip_types * added 15ms latency to image delivery with zero benefit.

Edge case — fonts: font/woff benefits slightly, but font/woff2 should never be recompressed. Likewise, image/svg+xml compresses extremely well (often 70%+ savings) and is frequently missed in default configs.

Size Thresholds Matter

Below ~1 KB, compression can be counterproductive. The gzip/brotli headers plus dictionary overhead can make the compressed version larger than the original, and the CPU cost isn't justified. Most edge configurations set a minimum threshold of 1 KB or 2 KB. Similarly, very large files (>5 MB) may benefit from chunked compression or leaving compression to the origin with range request support, since compressing huge payloads on the fly can tie up edge workers.

Negotiating Compression at the Edge: Accept-Encoding, Vary and Cache Key Design

Compression negotiation is a form of Content Negotiation at the CDN: Accept, Vary and Client Hints. The client sends Accept-Encoding: gzip, br, zstd (or a subset), the server chooses one encoding, responds with Content-Encoding: br, and must include Vary: Accept-Encoding so intermediate caches know the response varies by encoding.

The classic mistake is normalizing the cache key incorrectly. If you cache app.js without varying on encoding, the first client's encoding choice gets cached and served to everyone else — a Gzip client might receive Brotli and fail to decode. I've debugged this exact issue where an origin sent Content-Encoding: br but the CDN cached it under a single key without Vary, causing 2% of users (older Android WebViews with only Gzip) to get garbled responses.

At the CDN layer, you have two approaches:

1. Vary-based collapsing: Store separate cache entries per encoding. Cache key becomes URL + normalized Accept-Encoding bucket. Typically you bucket into br, zstd, gzip, identity. This is simple and correct, but it multiplies cache entries by up to 3-4x, which can reduce hit ratio if your cache is small.

2. Single-encoding origin + edge re-encoding: Origin stores one uncompressed (or one Gzip) version, edge decompresses and recompresses to the client's preferred encoding. More CPU at the edge, but better cache efficiency. Some providers decompress Gzip from origin and recompress to Brotli on the fly for clients that support it.

In my experience, for static assets, precompressed variants with Vary-based storage is superior — you pay no CPU and storage is cheap. For dynamic HTML, edge re-encoding is often more efficient because you only cache one variant at the origin.

# VCL / Edge logic pseudo-code for correct negotiation
# Normalize Accept-Encoding and set cache variant

sub vcl_recv {
    if (req.http.Accept-Encoding ~ "br") {
        set req.http.X-Encoding = "br";
    } elsif (req.http.Accept-Encoding ~ "zstd") {
        set req.http.X-Encoding = "zstd";
    } elsif (req.http.Accept-Encoding ~ "gzip") {
        set req.http.X-Encoding = "gzip";
    } else {
        set req.http.X-Encoding = "identity";
    }
    # Add to cache key
    set req.http.Vary = "Accept-Encoding";
}

sub vcl_backend_response {
    # Ensure origin Vary is preserved
    if (beresp.http.Vary) {
        set beresp.http.Vary = beresp.http.Vary + ", Accept-Encoding";
    } else {
        set beresp.http.Vary = "Accept-Encoding";
    }
    # Don't compress already-compressed types
    if (beresp.http.Content-Type ~ "image/|video/|audio/|font/woff2") {
        set beresp.do_gzip = false;
        set beresp.do_brotli = false;
    }
}

Also consider interaction with other caching headers. As covered in HTTP Caching Headers: Cache-Control, ETag, Vary and Conditional Requests, ETag should be encoding-aware. A strong ETag for a Gzip variant is not the same as for a Brotli variant. Use weak ETags (W/"...") or Vary-aware ETags to avoid conditional request mismatches.

Operationalizing Brotli and Zstandard: Precompression, Dynamic Tiers and Fallback Logic

Rolling out modern compression safely requires more than flipping a config flag. Here's a deployment pattern I've used successfully for several high-traffic properties.

Start with passive measurement. Log Accept-Encoding values at the edge for a week to understand your client mix. In 2024-2025, typical global traffic is ~96-98% Brotli-capable, ~70-80% Zstd-capable (rapidly rising), and 100% Gzip-capable. If your audience includes significant embedded clients, native apps with old HTTP stacks, or corporate proxies that strip encodings, your Zstd share may be lower.

Next, enable Brotli for dynamic compression at low levels (Brotli 4) with Gzip fallback. Monitor edge CPU — expect a 10-25% increase in compression CPU, which is usually offset by reduced bandwidth. Watch for 5xx errors related to clients that claim Brotli support but handle it poorly; I've seen older versions of Java's HttpURLConnection advertise br but fail to decode it when the server honors it via a transparent proxy.

For Zstandard, I recommend a progressive approach: first enable it for same-origin API traffic where you control the client (mobile apps, SPAs fetching JSON from your API). Train a dictionary for your most common JSON schemas and ship it to the edge. The gains are substantial — one e-commerce API I tuned went from 42 KB average JSON (Gzip) to 26 KB (Zstd + dictionary), a 38% reduction, which directly improved app startup time on 4G.

For static assets, precompress everything. Don't rely on dynamic compression for your Webpack/Vite bundles. The ideal origin response for /assets/app.9c2e1.js is to have app.9c2e1.js.br and app.9c2e1.js.zst already on disk or in object storage, served with correct Content-Encoding and Content-Type without invoking any compressor.

Monitoring and Safeguards

Set alerts for three things: edge CPU per request (p95), compression ratio per Content-Type, and Content-Encoding mismatch errors. Log the chosen encoding as a dimension in your CDN analytics so you can see hit ratios per encoding. If you see your Brotli hit ratio is much lower than Gzip, you may have a cache key fragmentation issue.

Finally, remember that compression is not a substitute for minification or proper cache design. Brotli on unminified JavaScript still wastes bytes. And a 304 Not Modified with a correct ETag is always cheaper than resending a compressed 200. Pair compression with strong caching and 103 Early Hints: The Server Push Replacement That Actually Works for preloading compressed critical assets, and you'll see compounding performance wins rather than isolated improvements.

Frequently Asked Questions

Should I replace Gzip entirely with Brotli or Zstandard?

No. Gzip remains your universal fallback. Roughly 2-4% of traffic — older bots, legacy HTTP clients, and some corporate proxies — still only support Gzip. Serve Brotli and Zstandard preferentially to clients that advertise them, and fall back to Gzip otherwise. Keep Gzip enabled at the edge and ensure your origin can always serve it. The goal is tiered negotiation, not replacement.

What compression level should I use for on-the-fly edge compression?

For dynamic compression at the edge, use Brotli 4 or 5 and Gzip 4-6. These levels capture 85-90% of the maximum compression ratio for only 2-5% of the maximum CPU cost. Reserve Brotli 11 and Zstd 19-22 for build-time precompression of static assets where CPU time is spent once and amortized over millions of requests. Using max levels on-the-fly will increase TTFB and can overload edge workers under traffic spikes.

Is Zstandard ready for production HTTP delivery?

Yes, with caveats. All major browsers except some older Safari versions now support Content-Encoding: zstd, and major CDNs like Cloudflare, Fastly, and Akamai can serve it. In my experience, Zstd at level 3 is an excellent choice for JSON APIs and same-origin dynamic content, especially with dictionary training. For public web assets, keep Brotli as the primary modern encoding and offer Zstd opportunistically — advertise it via Accept-Encoding and serve it when the client supports it, but don't depend on it as the only modern option yet.

How does compression interact with CDN caching and the Vary header?

You must include Vary: Accept-Encoding on any compressed response, and your cache key must account for the chosen encoding. Without Vary, a cache may serve a Brotli-encoded response to a Gzip-only client, causing decoding failures. Most CDNs automatically vary the cache key when you enable multiple encodings, but if you do custom edge logic or serve precompressed files from origin, verify that ETag and Vary are set correctly and that each encoding has its own cache variant.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker