Edge Compression & Asset Optimization
Cut bytes on the wire by negotiating the best compression codec per request at the CDN edge and re-encoding assets without round-tripping to origin.
Compression is the cheapest performance lever you control: it shrinks transfer size 60–80% for text payloads with near-zero origin cost, because the CDN does the work once and serves the encoded result from cache to every matching request. The hard part is not “turn on gzip” — it is negotiating the right codec via Accept-Encoding, keying the cache so a Brotli client never gets a gzip body (and vice versa), and never wasting CPU re-compressing already-compressed images or video. This guide covers the codec landscape (gzip, Brotli, Zstandard), the Vary: Accept-Encoding implication for your cache key, content-type targeting, minification, and image optimization across Cloudflare, AWS CloudFront, Fastly, and GCP.
Key implementation points:
- Negotiate codec from the request
Accept-Encodingheader and emitContent-EncodingplusVary: Accept-Encodingso caches stay correct. - Compress only compressible MIME types (HTML, CSS, JS, JSON, SVG, fonts); skip JPEG, PNG, WebP, MP4, and pre-
.gzassets. - Store each encoding as a distinct cache variant so the hit ratio survives across mixed clients — see Cache Key & Vary Configuration.
- Offload image re-encoding (Polish, CloudFront + Lambda@Edge, Fastly IO) and minification to the edge, and cache the optimized output.
How edge compression works at the protocol level
Compression is a content negotiation dance defined by HTTP. The client advertises what it can decode in the Accept-Encoding request header, often with quality weights: Accept-Encoding: br;q=1.0, gzip;q=0.8, *;q=0.1. The server (or, at the edge, the CDN) picks one supported codec, compresses the body, and stamps the response with Content-Encoding: br. Critically it must also emit Vary: Accept-Encoding, which tells every downstream cache that the response body differs by the value of that request header. Without Vary, a shared cache can hand a Brotli-encoded body to a client that only sent Accept-Encoding: gzip, producing garbled output and ERR_CONTENT_DECODING_FAILED errors in browsers.
At the edge the negotiation usually happens twice. The browser-to-edge hop negotiates the codec the user actually receives. The edge-to-origin hop is a separate negotiation — most CDNs request Accept-Encoding: gzip (or nothing) from origin, then decompress and recompress at the edge so they can serve the smallest codec the client supports regardless of what origin produced. This is why turning on Brotli at a CDN works even when origin only knows gzip: the edge owns the final encode. It also means the encoded variant is what lands in the cache, and your effective hit ratio depends on how many encoding variants you fan out into — covered in depth under cache key and Vary configuration.
The interplay with TTL matters too. A compressed object and its freshness lifetime are independent: Cache-Control: max-age=31536000 governs how long the edge keeps each encoding variant, while Content-Encoding governs which bytes that variant holds. Set your freshness directives deliberately in Cache-Control & CDN TTL so highly compressible static assets (hashed JS/CSS bundles) get long immutable lifetimes and the expensive Brotli-11 encode amortizes across millions of hits.
Choosing a codec: gzip vs Brotli vs Zstandard
The three codecs occupy distinct points on the ratio-versus-CPU curve, and the right choice depends on whether the content is dynamic (encoded per response) or static (encoded once and cached).
- gzip (DEFLATE) is the universal floor. Every HTTP client built in the last 25 years sends
gzipinAccept-Encoding. It is fast to encode at levels 1–6 and decodes trivially. Use it as the guaranteed fallback and for dynamic responses where you cannot afford a heavy encode. - Brotli (
br) wins on ratio for static text. At quality 11 it typically beats gzip by 15–25% on HTML/CSS/JS, but level-11 encoding is far too slow for per-request dynamic content — reserve it for cacheable assets where you pay the CPU once. For dynamic responses, Brotli quality 4–5 is a sweet spot that beats gzip-6 at comparable speed. Browser support forbris universal over HTTPS (Brotli is only advertised on TLS connections). - Zstandard (
zstd) is the newest entrant, advertised by Chromium-based browsers since 2024 and bycurl, CLIs, and service-to-service clients. Its key advantage is a wide, tunable speed/ratio range: at high levels it rivals Brotli’s ratio while decoding several times faster, which helps low-power clients. Support is not yet universal, so it must coexist with Brotli and gzip rather than replace them.
The practical rule: enable all three, let Accept-Encoding quality values and your CDN’s preference order decide, and always keep gzip as the safety net. A deep, provider-by-provider walkthrough of enabling the two modern codecs lives in Enabling Brotli and Zstandard Compression at the Edge.
Content-type targeting: what to compress and what to leave alone
Compression only helps entropy-rich text. Already-compressed binary formats — JPEG, PNG, WebP, AVIF, GIF, MP4, WebM, MP3, most PDFs, and pre-built .gz/.br artifacts — gain nothing and frequently grow slightly when run through DEFLATE, while burning CPU. Worse, re-compressing them risks double encoding (a .gz file served with an additional Content-Encoding: gzip), which breaks clients.
Target the compressible families explicitly. A safe allowlist: text/html, text/css, text/plain, text/xml, application/javascript, application/json, application/xml, image/svg+xml (SVG is text), application/wasm, and font formats font/ttf/font/otf (but not font/woff2, which is already Brotli-compressed internally). Most CDNs ship a default compressible-MIME list; verify it covers your API’s JSON and your SVG icons, and confirm it excludes image/* raster types.
Provider-specific implementation
Cloudflare
Cloudflare compresses automatically based on content type and the client’s Accept-Encoding. Brotli is toggled in the dashboard (Speed → Optimization → Content Optimization) or via API, and zstd is negotiated automatically when the client supports it. For fine control, a Worker lets you override negotiation or compress responses your origin returns uncompressed:
export default {
async fetch(request) {
const res = await fetch(request);
const ct = res.headers.get("content-type") || "";
const accepts = request.headers.get("accept-encoding") || "";
// Only touch compressible text; never raster images
if (!/^(text\/|application\/(json|javascript|xml)|image\/svg)/.test(ct)) {
return res;
}
const body = res.body;
const headers = new Headers(res.headers);
headers.set("vary", "accept-encoding");
// Cloudflare's CompressionStream supports gzip + deflate; br/zstd are
// handled by the platform layer when enabled.
if (accepts.includes("gzip")) {
headers.set("content-encoding", "gzip");
return new Response(
body.pipeThrough(new CompressionStream("gzip")),
{ status: res.status, headers }
);
}
return new Response(body, { status: res.status, headers });
}
};
Cloudflare Polish is the image counterpart: enable it (Speed → Optimization → Image Optimization) in Lossless or Lossy mode, and add WebP/AVIF conversion so the edge re-encodes raster images and serves the smallest format the browser’s Accept header allows. Image Resizing (/cdn-cgi/image/... or the cf.image Worker option) resizes and re-encodes on the fly, caching each variant.
Two Cloudflare behaviors catch people out. First, the zone toggle only governs what Cloudflare encodes itself: if your origin already returns Content-Encoding: gzip, the edge passes those bytes through untouched rather than decoding and re-encoding them, so a gzip-only origin quietly caps the ratio your visitors ever see. Have origin serve identity to the edge and let the platform own the final encode. Second, Compression Rules scope codec choice by expression — path, extension, or content type — which is the supported way to force br on a route the default MIME list skips, or to force identity on a text/event-stream endpoint. Rules evaluate before the response is written to cache, so whatever they select is the encoding that gets stored as the variant.
AWS CloudFront
CloudFront compresses objects when you enable Compress objects automatically on the cache behavior, the viewer sends Accept-Encoding: gzip or br, and the file matches a compressible content type within the 1 KB–10 MB size window. The cache policy must forward Accept-Encoding correctly — use the managed CachingOptimized policy, which normalizes the header into gzip/br flags so you do not blow up the cache key:
resource "aws_cloudfront_distribution" "site" {
default_cache_behavior {
target_origin_id = "origin-app"
viewer_protocol_policy = "redirect-to-https"
compress = true # gzip + Brotli auto-compression
cache_policy_id = data.aws_cloudfront_cache_policy.optimized.id
}
}
data "aws_cloudfront_cache_policy" "optimized" {
name = "Managed-CachingOptimized"
}
For image optimization, pair CloudFront with Lambda@Edge (or CloudFront Functions for lighter logic) on the origin-response event to resize/re-encode using sharp, then cache the result:
// Lambda@Edge origin-response: convert to WebP when the viewer accepts it
const sharp = require("sharp");
exports.handler = async (event) => {
const res = event.Records[0].cf.response;
const req = event.Records[0].cf.request;
const accept = (req.headers["accept"]?.[0]?.value) || "";
if (res.status === "200" && accept.includes("image/webp")) {
const buf = Buffer.from(res.body, "base64");
const webp = await sharp(buf).webp({ quality: 80 }).toBuffer();
res.body = webp.toString("base64");
res.bodyEncoding = "base64";
res.headers["content-type"] = [{ key: "Content-Type", value: "image/webp" }];
res.headers["vary"] = [{ key: "Vary", value: "Accept" }];
}
return res;
};
Fastly
Fastly does not auto-compress by default in the same way; you enable gzip via VCL or beta-fetch settings and target content types with set beresp.gzip. Brotli is supported at the edge for cacheable objects, and Fastly Image Optimizer (IO) handles raster re-encoding and resizing through URL query parameters:
sub vcl_fetch {
# Compress text responses; never touch images/video
if (beresp.http.Content-Type ~ "^(text/|application/(javascript|json|xml)|image/svg)") {
set beresp.gzip = true;
}
# Ensure caches key on the encoding
if (beresp.http.Content-Encoding) {
set beresp.http.Vary = "Accept-Encoding";
}
}
Fastly IO usage (?format=webp&width=800&quality=80) re-encodes at the edge and caches per-variant, with auto=webp letting the platform choose based on Accept.
Two details separate Fastly from the automatic platforms. set beresp.gzip = true marks the object for gzip only; Brotli lives in a separately stored br variant that the platform builds for cacheable objects, so a route marked pass never receives Brotli no matter what the client advertises. And because Vary participates in an object’s identity, adding Accept-Encoding to Vary in vcl_fetch after objects have already been hashed does not retroactively split what is in cache — you need a purge to rebuild the variant set. Fastly also reads Surrogate-Control independently of Cache-Control, which lets you hold a long edge lifetime for an expensively encoded asset while handing clients a much shorter freshness window.
Azure & GCP
Azure Front Door enables compression per route with a configurable content-type list and negotiates gzip/Brotli automatically. Google Cloud CDN compresses dynamic origin responses (gzip/Brotli) when dynamic compression is enabled on the backend service and caches each encoding separately. Both follow the same Vary: Accept-Encoding contract; the main operational difference is the compressible-type allowlist and the min/max object size thresholds.
The thresholds are where these two bite. Front Door only compresses objects inside a size band of roughly 1 KB to 8 MB and applies its type list per route, so a route inheriting the default list silently skips a custom MIME such as application/manifest+json until you add it explicitly. Google Cloud CDN draws a sharper line between backend services and backend buckets: dynamic compression is a backend-service feature, while a bucket serves exactly the bytes and metadata you uploaded. A bucket-backed site therefore needs .br and .gz objects uploaded alongside the identity copy, each carrying matching Content-Encoding metadata, because nothing in that path will encode on your behalf.
Codec and provider support matrix
| Codec / Feature | Best for | Wire behavior | Provider support / notes |
|---|---|---|---|
| gzip | Dynamic + universal fallback | Content-Encoding: gzip, decode everywhere |
All CDNs; always keep enabled |
Brotli (br) |
Static text, smallest size | Content-Encoding: br, HTTPS-only advertise |
Cloudflare, CloudFront, Fastly, Front Door, GCP |
Zstandard (zstd) |
Fast decode, modern clients | Content-Encoding: zstd, partial client support |
Cloudflare (auto); CloudFront/others limited |
| WebP / AVIF | Raster image re-encode | New Content-Type, Vary: Accept |
Polish, Lambda@Edge+sharp, Fastly IO |
| Minification | JS/CSS/HTML byte reduction | Same Content-Type, smaller body |
Cloudflare Auto Minify (deprecating — prefer build-time) |
Step-by-step enablement procedure
- Inventory content types. Pull a sample of responses and group by
Content-Type. Confirm your compressible allowlist covers HTML, CSS, JS, JSON, and SVG, and excludes raster images, video,woff2, and pre-compressed archives. - Enable codecs at the edge. Turn on gzip and Brotli on every cache behavior/route; enable zstd where the provider offers it. Keep gzip enabled as the floor.
- Confirm
Vary: Accept-Encoding. Ensure compressed responses carry the header so shared caches store per-encoding variants. Without it, mixed-client caches corrupt. - Normalize the cache key. Use the provider’s managed policy (CloudFront
CachingOptimized, Cloudflare default) soAccept-Encodingcollapses to a small set of variants rather than every raw header string. - Layer image optimization. Enable Polish/IO or wire a Lambda@Edge
sharpstep for WebP/AVIF, withVary: Accepton those responses. - Move minification to build time. Minify JS/CSS/HTML in your bundler (esbuild, Terser) rather than relying on edge auto-minify, which is being deprecated and can break inline scripts.
- Verify on the wire. Test each codec path:
# Request Brotli and confirm the edge honors it
curl -sI -H "Accept-Encoding: br" https://example.com/app.js | grep -i 'content-encoding\|vary'
# Force gzip
curl -sI -H "Accept-Encoding: gzip" https://example.com/app.js | grep -i content-encoding
# zstd-capable client
curl -sI -H "Accept-Encoding: zstd" https://example.com/app.js | grep -i content-encoding
# Confirm images are NOT re-compressed (expect no content-encoding)
curl -sI -H "Accept-Encoding: br,gzip" https://example.com/logo.png | grep -i content-encoding
Expected: the JS responses return content-encoding: br / gzip / zstd respectively with vary: accept-encoding; the PNG returns no content-encoding.
Measuring compression in production
Enablement is easy to fake. A toggle can read “on” while a third of your traffic still leaves the edge as identity because of a MIME gap, a size threshold, or a route that bypasses the cache. Three numbers turn the guesswork into evidence.
Coverage is the share of compressible responses that actually shipped encoded. Every major CDN exposes the response Content-Encoding (or an equivalent compression flag) in its log stream, so this is one group-by away:
# Cloudflare Logpush sample: encoding coverage grouped by content type
jq -r 'select(.EdgeResponseContentType | test("text/|json|javascript|svg"))
| [.EdgeResponseContentType, (.EdgeResponseHeaderContentEncoding // "identity")]
| @tsv' logs.json \
| sort | uniq -c | sort -rn | head -20
A stubborn identity row against a text type means one of three things: the MIME is outside the allowlist, the object is under the minimum size, or the route is marked uncacheable and the platform declined to spend CPU on it.
Realized savings compares bytes actually billed against the identity size the origin would have shipped. Grouping edge response bytes by object class shows where the remaining fat is; a healthy configuration lands 70–80% off HTML, JS, and JSON and effectively nothing off media, which is exactly the shape you want.
Encoding mix is the split between br, zstd, gzip, and identity across live traffic. A sudden gzip spike usually means either a new client population that never negotiated TLS (so Brotli was never advertised) or an intermediary rewriting the request header — corporate proxies routinely normalize Accept-Encoding down to gzip or drop it altogether.
Wire a synthetic probe into monitoring so a regression fails loudly instead of surfacing weeks later as a slow creep in transfer bytes:
#!/usr/bin/env bash
set -euo pipefail
url="https://example.com/app.js"
hdrs=$(curl -sI -H 'Accept-Encoding: br, gzip' "$url")
enc=$(printf '%s' "$hdrs" | awk -F': ' 'tolower($1)=="content-encoding"{print $2}' | tr -d '\r')
vary=$(printf '%s' "$hdrs" | awk -F': ' 'tolower($1)=="vary"{print $2}' | tr -d '\r')
[ "$enc" = "br" ] || { echo "FAIL: expected br, got '${enc:-identity}'"; exit 1; }
printf '%s' "$vary" | grep -qi 'accept-encoding' || { echo "FAIL: Vary missing Accept-Encoding"; exit 1; }
echo "ok: encoding=$enc vary=$vary"
Run it per environment and per critical route rather than once against the homepage. The failures that matter in practice are almost always confined to a single cache behavior, a single path prefix, or a single content type — a homepage check will happily stay green through all of them.
Caching, Vary, and propagation implications
Every distinct Content-Encoding you serve is a separate object in the cache. Vary: Accept-Encoding is what makes that correct, but it also multiplies storage and can dilute hit ratio if the CDN keys on the raw header value instead of a normalized codec set. A client sending Accept-Encoding: gzip, deflate, br, zstd and another sending br, gzip should land on the same Brotli variant — that only happens if the CDN normalizes. CloudFront’s managed cache policies and Cloudflare’s default behavior do this for you; on Fastly or custom setups, normalize in VCL or your Worker before the lookup.
Because compressed objects are cached, changing your codec or compression level does not retroactively re-encode what is already stored. New encodings apply to fresh fetches. If you raise Brotli quality or switch a behavior from gzip to br, purge the affected paths so the edge re-fetches and re-encodes — invalidation procedure lives in your cache purging guide. Long max-age on hashed assets means an old gzip variant can linger for the full TTL otherwise.
Troubleshooting & rollback protocol
| Symptom | Likely cause | Fix |
|---|---|---|
ERR_CONTENT_DECODING_FAILED in browser |
Double compression (origin sent gzip, edge added br) | Strip Content-Encoding at origin or disable edge compress for that path |
| Garbled body to some clients only | Missing Vary: Accept-Encoding in shared cache |
Add Vary header; purge cache |
| Compression not applied | Object outside size window or non-allowlisted MIME | Check min/max size limits and add the content type |
| Images slightly larger | Re-compressing already-compressed raster | Exclude image/* raster types from compression |
| Low Brotli hit ratio | Cache keyed on raw Accept-Encoding |
Normalize header to gzip/br/zstd set |
.gz file served with extra encoding |
Compressing a pre-compressed artifact | Set correct Content-Encoding at origin, exclude from edge compress |
Rollback: if compression breaks a client segment, the fast revert is to disable the offending codec (usually a newly added Brotli or zstd toggle) on the cache behavior, leaving gzip active, then purge affected paths. Because gzip is universally decodable, this restores correctness immediately while you debug the Vary/double-encoding root cause.
Scenario: the hit ratio fell the week Brotli went on
The symptom is a cache hit ratio dropping ten to twenty points with no change in traffic mix, while origin egress climbs even though response bodies got smaller. The cause is nearly always variant fan-out. The edge is now storing one object per encoding, and if it keys on the raw Accept-Encoding string, every unusual ordering or quality-value permutation mints a brand-new object that must be fetched and encoded before it can ever serve a hit.
Diagnose it by counting distinct inbound header values in your logs:
jq -r '.ClientRequestHeaderAcceptEncoding // "none"' logs.json \
| sort | uniq -c | sort -rn | head
Healthy output has three or four dominant rows carrying nearly all the traffic. A long list of dozens of near-identical strings means nothing is normalizing the header. The fix is either the provider’s own encoding flags, or a canonical rewrite before the cache lookup:
sub vcl_recv {
if (req.http.Accept-Encoding ~ "br") {
set req.http.Accept-Encoding = "br";
} else if (req.http.Accept-Encoding ~ "gzip") {
set req.http.Accept-Encoding = "gzip";
} else {
unset req.http.Accept-Encoding;
}
}
After the change, purge the affected paths so the fragmented objects are rebuilt as a small canonical set — scope that purge narrowly, using the techniques in cache purging and invalidation, rather than emptying the zone. Expect the ratio to recover over roughly one full TTL cycle as the surviving variants warm, not instantly.
Edge cases & gotchas
woff2is already Brotli inside. Compressing it again wastes CPU for ~0% gain — exclude it.- Brotli only over HTTPS. Browsers do not advertise
bron plaintext HTTP, so testing overhttp://will silently fall back to gzip. - Range requests + compression conflict. A
Rangerequest on a compressed object is ambiguous; most CDNs serve ranges only on identity encoding. Don’t expect byte-range resumes on Brotli bodies. - SSE / streaming. Buffered compression breaks Server-Sent Events and chunked streaming responses — disable compression for
text/event-stream. Content-LengthvsTransfer-Encoding. Compressed streamed responses dropContent-Length; tooling that requires it must handle chunked transfer.- ETag changes per encoding. Some servers append
-gzipto the ETag for compressed variants; weak ETags avoid false 304 mismatches across encodings. Accept-Encodingcan be stripped in transit. Corporate middleboxes and older load balancers rewrite or remove the header before it reaches the edge, so those users silently receive identity bodies. If one network or region shows anomalous transfer sizes, log the inbound header before blaming the CDN.- A hard-coded
Content-Lengthbreaks encoded paths. The length your application computes describes the identity body. If a framework pins it and the edge encodes afterwards, clients see truncated or hung responses — let the platform own the length header wherever encoding happens downstream. - Minification and compression are not substitutes. A compressor already models the whitespace and repeated identifiers a minifier removes, so stacking them buys perhaps two to five percent over compression alone. Minify for parse time and bundle hygiene, not for transfer size.
- Brotli’s built-in dictionary favors markup. Its static dictionary is trained on web text, which is why the lead over gzip is widest on hand-written HTML and narrowest on minified bundles or machine-generated JSON. Do not assume one savings percentage generalizes across your content types.
- Compression moves cost between billing lines. Egress is billed on the compressed bytes, but origin-fetch volume is billed on whatever the origin sent. An edge that pulls identity and encodes locally shifts spend rather than eliminating it — worth knowing before you quote a savings figure.
- Test with a realistic request. A bare
curl -Iadvertises no encodings at all and will happily report identity, convincing you compression is broken when it is working perfectly. Always pass an explicitAccept-Encoding.
Frequently Asked Questions
Should I compress at origin or at the edge? Prefer the edge for cacheable assets so the expensive Brotli-11 encode happens once and amortizes across all hits, and so the CDN can serve the smallest codec each client supports independent of origin. Origin compression makes sense only for uncacheable dynamic responses behind a CDN that won’t re-encode, but watch for double compression.
Why does my image get a little bigger when compressed?
JPEG, PNG, WebP, and similar formats are already entropy-coded, so running DEFLATE or Brotli over them adds container overhead without removing redundancy. Exclude all raster image/* types from text compression and use a dedicated image optimizer (Polish, Fastly IO, Lambda@Edge) for those instead.
Is Zstandard ready to replace Brotli?
Not yet for browser-facing static assets — zstd advertisement is still limited to newer Chromium clients and CLI tooling. Enable it alongside Brotli and gzip so capable clients benefit from its fast decode while everyone else falls back. It already shines for service-to-service and CLI download traffic.
Do I always need Vary: Accept-Encoding?
Yes whenever a shared cache (CDN, proxy) stores compressed responses. Without it a cache can serve a Brotli body to a gzip-only client, causing decode failures. The header is what lets the cache store and select per-encoding variants correctly.
How should I handle streaming responses such as Server-Sent Events?
Leave them uncompressed. Buffered compression withholds bytes until it has a block worth encoding, which defeats the purpose of a stream and can stall an SSE consumer for an unbounded time. Exclude text/event-stream and any chunked long-poll endpoint from the compressible list; individual events are small enough that the lost savings are negligible.
Does enabling three codecs triple my edge storage? Not meaningfully, because real traffic concentrates on one or two codecs and each encoded copy is a fraction of the original size. The genuine risk is cache-key fragmentation rather than volume: if the edge keys on the raw request header instead of a normalized codec token, you fan out into far more variants than the three you intended, and every one of them starts cold.