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-Encoding header and emit Content-Encoding plus Vary: Accept-Encoding so caches stay correct.
  • Compress only compressible MIME types (HTML, CSS, JS, JSON, SVG, fonts); skip JPEG, PNG, WebP, MP4, and pre-.gz assets.
  • 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.
Edge compression negotiation flow A request arrives with an Accept-Encoding header; the edge checks content type, then picks zstd, Brotli, gzip, or identity, and stores the result as a Vary variant. Request arrives Accept-Encoding: … Content-Type compressible? image/video? serve identity no yes → choose codec zstd best ratio/speed br (Brotli) smallest static gzip universal fallback identity none offered Emit Content-Encoding + Vary: Accept-Encoding Store as per-encoding cache variant

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 gzip in Accept-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 for br is universal over HTTPS (Brotli is only advertised on TLS connections).
  • Zstandard (zstd) is the newest entrant, advertised by Chromium-based browsers since 2024 and by curl, 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

  1. 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.
  2. 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.
  3. Confirm Vary: Accept-Encoding. Ensure compressed responses carry the header so shared caches store per-encoding variants. Without it, mixed-client caches corrupt.
  4. Normalize the cache key. Use the provider’s managed policy (CloudFront CachingOptimized, Cloudflare default) so Accept-Encoding collapses to a small set of variants rather than every raw header string.
  5. Layer image optimization. Enable Polish/IO or wire a Lambda@Edge sharp step for WebP/AVIF, with Vary: Accept on those responses.
  6. 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.
  7. 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

  • woff2 is already Brotli inside. Compressing it again wastes CPU for ~0% gain — exclude it.
  • Brotli only over HTTPS. Browsers do not advertise br on plaintext HTTP, so testing over http:// will silently fall back to gzip.
  • Range requests + compression conflict. A Range request 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-Length vs Transfer-Encoding. Compressed streamed responses drop Content-Length; tooling that requires it must handle chunked transfer.
  • ETag changes per encoding. Some servers append -gzip to 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.

Back to CDN Caching & Performance Optimization