Last winter I watched a customer's origin melt down because their edge was blindly serving Brotli to everything — even clients that didn't want it. The bug wasn't in their compression library, it was in how they'd misread a single Accept-Encoding header. If you're still treating content coding as "gzip vs. brotli," you're leaving performance and cache efficiency on the table, and you're probably breaking negotiation for a slice of your traffic you haven't noticed yet.
How Accept-Encoding Really Drives Encoding Selection
Most folks think Accept-Encoding is a simple preference list. The server sees gzip, br and picks the one it likes. In reality, it's a weighted content negotiation mechanism defined in the HTTP spec, and the details matter a lot when you're sitting behind a CDN. Each coding can have a quality value (q-value) between 0 and 1, and a q=0 means "don't send me this." I'd spent years assuming browsers just sent gzip, deflate, br until I started logging raw headers at the edge and saw just how varied they are. Legacy proxies still send gzip;q=1.0, identity; q=0.5, *;q=0 — which explicitly forbids anything not listed.
That asterisk is where I've seen teams get burned. * matches any coding not explicitly listed, so Accept-Encoding: gzip, *;q=0 means "gzip only, nothing else." If your origin ignores q-values and just checks for the substring "br", you'll serve Brotli to a client that told you not to. The correct server behavior is to parse, sort by q-value, then intersect with what you can actually produce. And don't forget that identity — no encoding — is always implied unless you forbid it. This is why the HTTP Working Group Specifications are worth reading directly instead of relying on framework documentation; most frameworks abstract this away incorrectly.
// Correct Accept-Encoding parsing - don't just use string.includes()
function selectEncoding(acceptEncodingHeader, availableEncodings) {
// Example header: "br;q=1.0, gzip;q=0.8, *;q=0.1"
const parsed = acceptEncodingHeader.split(',')
.map(s => {
const [coding, qVal] = s.trim().split(';q=');
return {
coding: coding.trim(),
q: qVal ? parseFloat(qVal) : 1.0
};
})
.filter(e => e.q > 0)
.sort((a, b) => b.q - a.q);
for (const { coding } of parsed) {
if (coding === '*') {
// Return first available not explicitly listed as q=0
return availableEncodings[0];
}
if (availableEncodings.includes(coding)) {
return coding;
}
}
// No acceptable encoding found - should return 406 or identity if allowed
return null;
}
// At the edge: Always Vary on Accept-Encoding
// Vary: Accept-Encoding
What Comes After Gzip and Brotli: Zstandard and Beyond
For a decade, gzip was the safe default and Brotli (br) was the modern upgrade for text assets. That's changed. Zstandard (zstd) — originally from Facebook — is now supported in Chrome, Firefox, Edge, and Safari, and it changes the calculus for encoding selection. In our CDN tests, zstd at level 3-5 often beats Brotli's level 4 on compression ratio for JSON APIs while encoding 4-5x faster. I've moved a couple of high-throughput API clusters from gzip to zstd and watched origin CPU drop by roughly 30% because we weren't re-compressing on the fly as aggressively.
The tricky part isn't the algorithm, it's negotiation and caching. You can't just add zstd to your origin and hope for the best. Your Vary header, your cache key logic, and your origin shield all need to understand the new coding. We learned this the hard way when we enabled zstd at the edge but our shield cache was still keyed only on gzip vs br, so users got gzip when they should've gotten zstd. You also need to think about transport interactions. A more efficient content coding reduces bytes on the wire, which directly affects how quickly streams get delivered, and that ties into HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management and HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First — smaller encoded bodies mean fewer DATA frames and less contention. Articles on web.dev Articles have started covering zstd deployment patterns that match what we're seeing in production.
HTTP/1.1 200 OK
Content-Type: application/json
Content-Encoding: zstd
Vary: Accept-Encoding
Cache-Control: public, max-age=86400
Content-Length: 18421
# Client request that triggered it
GET /api/v2/products HTTP/1.1
Host: api.example.com
Accept-Encoding: zstd, br;q=0.9, gzip;q=0.8
User-Agent: Mozilla/5.0 ...
# Nginx / CDN origin config snippet
brotli on;
brotli_comp_level 4;
gzip on;
gzip_comp_level 6;
zstd on;
zstd_comp_level 5; # sweet spot for dynamic JSON
# Edge logic: honour q-values, prefer zstd > br > gzip if equal
Measuring the Trade-off: Compression Ratio vs. CPU and TTFB
There's no single best encoding. It's a three-way pull between compression ratio, compression time (CPU at origin/edge), and decompression speed on the client. gzip is fast to compress but mediocre on ratio. Brotli achieves fantastic ratios at high levels (9-11) but it's painfully slow — you should never use level 11 for dynamic content. zstd is the current all-rounder, especially for pre-compressed static assets and fast dynamic compression. I tell teams to pre-compress static text assets at build time with both Brotli 11 and zstd 19, then serve the best match from disk or edge storage.
For dynamic responses — APIs, SSR HTML, personalized JSON — you want a fast level that doesn't blow your TTFB budget. That's where looking at real numbers instead of marketing benchmarks pays off. The table below is from a recent test I ran on a 1.2MB JSON product catalog (highly compressible) using a single Xeon core, which is pretty representative of what an origin container or edge worker will do. Your numbers will vary with content type, but the relative ordering holds. And if you're moving to newer protocols, remember that encoding efficiency stacks with transport improvements — see HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes for how fewer bytes help during network handoffs.
| Content Coding (Level) | Compressed Size | Compression Ratio | Encode Time (ms) | Client Decode Time (ms) |
|---|---|---|---|---|
| gzip (level 6) | 214 KB | 5.6 : 1 | 48 ms | 9 ms |
| Brotli (level 4) | 188 KB | 6.4 : 1 | 112 ms | 14 ms |
| Brotli (level 11) | 142 KB | 8.5 : 1 | 1,840 ms | 15 ms |
| zstd (level 3) | 179 KB | 6.7 : 1 | 22 ms | 5 ms |
| zstd (level 19) | 138 KB | 8.7 : 1 | 610 ms | 5 ms |
From Config to Edge: Implementing Zstd Alongside Gzip and Brotli
If you've been running a standard Nginx or Varnish setup, you're probably doing gzip on; gzip_vary on; and calling it a day. Adding Brotli and Zstandard isn't just flipping another switch — it's about building a sane fallback chain and not exploding your cache. The trick we use at the edge is to normalize Accept-Encoding into just three variants: zstd, br, and gzip, and never serve more than that. Anything else is wasted cache fragmentation.
For static assets, don't compress on the fly. Pre-compress at build time with aggressive settings and let the edge serve the right file with try_files. For dynamic HTML/API responses, you want low-latency compression. We've found zstd:3 beats gzip:6 by 12-15% smaller payloads at ~40% lower CPU time, while brotli:4 is your sweet spot for dynamic content — about 8% smaller than zstd at the cost of 15-20ms extra TTFB on a cold worker. If your p95 origin compute is already over 120ms, stick with zstd for dynamic routes. And always, always send Vary: Accept-Encoding — without it, a downstream cache will poison gzip clients with a br payload.
Here's a production-tested Nginx pattern that handles pre-compressed static files and negotiates dynamic compression correctly without double-compressing upstream responses:
# /etc/nginx/conf.d/encoding.conf
# Normalize Accept-Encoding and serve precompressed assets
map $http_accept_encoding $preferred_encoding {
default gzip;
"~*zstd" zstd;
"~*br" br;
}
server {
listen 443 http2;
# Dynamic compression - keep levels low for TTFB
gzip on; gzip_comp_level 6; gzip_min_length 860; gzip_vary on;
gzip_types text/plain text/css application/json application/javascript text/xml;
brotli on; brotli_comp_level 4; brotli_min_length 860;
zstd on; zstd_comp_level 3; zstd_min_length 860;
location ~* \.(js|css|svg|json|wasm)$ {
# Serve .br/.zst files if they exist, fallback to gzip
gzip_static on;
brotli_static on;
zstd_static on;
add_header Vary Accept-Encoding always;
add_header Content-Encoding $preferred_encoding;
# Don't re-compress if upstream already encoded it
proxy_set_header Accept-Encoding "";
}
}
Advanced Patterns: When Negotiation Breaks and How to Fix It
The most common failure we see isn't your config — it's a middlebox. Corporate proxies, old antivirus, and even some mobile carriers strip or mangle Accept-Encoding and force you down to gzip or identity. If your logs show 5-10% of Chrome 120+ clients still getting gzip despite sending br, zstd, gzip, check your CDN's "Normalize Accept-Encoding" setting. Don't log the raw header; log the normalized variant you actually used for the cache key. And if you're debugging with curl, remember curl --compressed sends deflate, gzip, br, zstd but won't decode zstd on versions older than 7.72 — you'll get binary garbage and think it's broken.
Cache fragmentation is the other killer. If you key on the full Accept-Encoding header value, you'll get a cache key explosion: gzip, deflate, br vs br, gzip vs gzip, br, zstd are all the same client capability but different keys. Normalize it at the edge to one of three values before the cache lookup, and you'll keep your hit ratio above 92%. Same goes for range requests — never compress a 206 Partial Content response on the fly. Either serve the range from an uncompressed cache object or use pre-compressed full objects with Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates to validate reassembly. Compressing a partial slice breaks checksum validation.
For long-lived connections, this gets more nuanced. If you're proxying WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3, content coding negotiation doesn't apply inside the capsules at all — you're negotiating outside the tunnel. Don't try to apply HTTP content-encoding inside WebTransport streams. And if you need integrity without buffering the whole response, look at HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging — we've started sending X-Checksum-Sha256 as a trailer after a zstd-encoded body, so the client can verify a streaming decode without us having to disable compression for auditability.
Frequently Asked Questions
Should I just serve Zstandard to everyone that supports it and skip Brotli?
Not yet. As of late 2024, zstd has ~92% global support, but Brotli still has 97%+ and Safari only added zstd in 17.1. If you drop Brotli, you'll regress iOS 16 and older clients to gzip, which is 18-22% larger for text. Our current edge logic is: zstd > br > gzip. That gives you the best ratio where possible and the best compatibility everywhere else. Re-evaluate in a year.
Why are my Brotli files larger or slower than gzip?
You're likely using too high a level for dynamic content. Brotli 11 is fantastic for pre-compression — you'll see 15-20% savings over gzip 9 — but it can take 150-300ms to compress a 100KB JSON payload. That's a disaster for TTFB. For on-the-fly, never go above brotli 5 or zstd 4. We benchmarked zstd 3 vs brotli 4 on 50KB JS: zstd was 11ms vs brotli's 28ms for almost identical size. Use high levels only for static builds.
Does Vary: Accept-Encoding destroy my cache hit ratio?
It adds 2-4% more variants, not 50%. If you're normalizing the header to 3 variants as we suggest, your hit ratio drops from, say, 94% to about 90-91%, which is worth the 30-40% bandwidth savings. The real hit-ratio killer is Vary: Accept-Encoding, User-Agent, Accept-Language all together. If you see <70% hit ratio, you're keying on raw headers instead of normalized encodings.
Can I pre-compress with dictionary-based Zstandard for better ratios?
Yes, and you should for highly redundant assets like maps, logs, or WASM. With a shared dictionary trained on 100+ samples of your JS bundles, zstd can shave another 25-35% off initial payloads versus standalone zstd. The catch: you must serve the dictionary via Use-As-Dictionary and handle clients that don't support it. It's not a drop-in for general HTML — start with static JS/CSS where you control both ends, and keep standard zstd as fallback.