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.
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.
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.
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.
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.
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.
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.