Enabling Brotli and Zstandard Compression at the Edge

Compressing responses at the CDN edge cuts transfer size by 60-80% on text payloads without touching your origin code. After reading this guide you will be able to turn on Brotli (and Zstandard where the platform supports it) on a proxied domain, force a correct Vary: Accept-Encoding header, exempt already-compressed media from re-compression, and prove with curl that real clients receive a br or zstd encoded body.

Key objectives:

  • Enable Brotli on Cloudflare and a compression behavior on CloudFront, plus a Worker fallback for full control.
  • Negotiate the right codec from the client Accept-Encoding header without breaking caches.
  • Emit Vary: Accept-Encoding so shared caches keep encoding variants separate.
  • Skip JPEG, PNG, WebP, MP4, and pre-gzipped assets to avoid wasted CPU and size regressions.
Edge codec selection from Accept-Encoding The client sends Accept-Encoding, the edge checks content type and header, then picks zstd, br, gzip, or identity and adds Vary. Client AE: br, zstd, gzip Edge node check type + Accept-Encoding request zstd (best) br (wide support) gzip (fallback) identity images, video, gzipped Response + Vary: Accept-Encoding encode + Vary

Prerequisites and environment setup

You need a domain proxied through your CDN (orange-cloud in Cloudflare, or an active CloudFront distribution in front of your origin). Compression only happens on responses that traverse the edge; a grey-cloud DNS-only record bypasses the proxy entirely, so confirm the record is proxied before you start.

Tool versions used below:

wrangler --version   # 3.90.0
aws --version        # aws-cli/2.15.40
curl --version       # curl 8.5.0 (must be built with brotli; check "brotli" in features)

Brotli (br) is supported by every current browser. Zstandard (zstd) shipped to Chrome and Edge stable in 2024 and is gated behind a secure context; Safari and many CLI tools still omit it from Accept-Encoding, so always keep br and gzip as fallbacks. Never serve an encoding the client did not advertise.

Step 1: Enable Brotli on Cloudflare

Cloudflare compresses eligible responses automatically, but verify the zone setting is on. Toggle it in the dashboard under Speed > Optimization > Content Optimization, or via the API:

curl -s -X PATCH \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/settings/brotli" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"value":"on"}' | jq '.result'

Expected output:

{ "id": "brotli", "value": "on", "editable": true }

Side effect: Cloudflare will Brotli-compress text-like content types (HTML, CSS, JS, JSON, SVG, XML) when the client sends Accept-Encoding: br, and emits Vary: Accept-Encoding automatically. It will not compress responses already carrying a Content-Encoding, nor binary media types. Cloudflare does not yet expose a zstd toggle on the standard plan, so for zstd you fall through to the Worker in Step 3.

Step 2: Configure compression on CloudFront

CloudFront compresses with gzip and Brotli when three conditions hold: the cache behavior has compression enabled, the viewer sends Accept-Encoding, and the cache policy includes that header in the cache key. Enable it on the behavior and attach a policy that varies on encoding.

resource "aws_cloudfront_distribution" "site" {
  # ... origin and other config ...
  default_cache_behavior {
    target_origin_id       = "origin-app"
    viewer_protocol_policy = "redirect-to-https"
    compress               = true            # enables gzip + brotli
    cache_policy_id        = aws_cloudfront_cache_policy.compress.id
  }
}

resource "aws_cloudfront_cache_policy" "compress" {
  name        = "compress-vary-encoding"
  min_ttl     = 0
  default_ttl = 86400
  max_ttl     = 31536000
  parameters_in_cache_key_and_forwarded_to_origin {
    enable_accept_encoding_brotli = true
    enable_accept_encoding_gzip   = true
    cookies_config  { cookie_behavior = "none" }
    headers_config  { header_behavior = "none" }
    query_strings_config { query_string_behavior = "none" }
  }
}

Setting enable_accept_encoding_brotli and enable_accept_encoding_gzip makes CloudFront normalize the header into the cache key and add Vary: Accept-Encoding for you. Do not also list Accept-Encoding manually in headers_config – that produces hundreds of cache key permutations and tanks your hit ratio. CloudFront only compresses objects between roughly 1 KB and 10 MB whose content type is on its compressible list; it has no native zstd path, so use the Worker or Lambda@Edge approach for that codec.

Step 3: Full control with a Cloudflare Worker

When you need zstd, custom type rules, or compression on responses the platform skips, run a Worker. This example negotiates the best codec the client advertised, refuses to touch already-compressed or binary bodies, and sets Vary. Pairing this with deliberate Cache-Control headers keeps each encoded variant correctly cached.

const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml|wasm)|image\/svg)/i;

function pickCodec(accept) {
  const ae = (accept || "").toLowerCase();
  if (ae.includes("zstd")) return "zstd";
  if (ae.includes("br")) return "br";
  if (ae.includes("gzip")) return "gzip";
  return null; // identity
}

export default {
  async fetch(request, env, ctx) {
    const res = await fetch(request);
    const type = res.headers.get("content-type") || "";

    // Skip if already encoded, not compressible, or no streaming body.
    if (res.headers.get("content-encoding") || !COMPRESSIBLE.test(type) || !res.body) {
      return res;
    }

    const codec = pickCodec(request.headers.get("accept-encoding"));
    if (!codec || codec === "zstd") {
      // CompressionStream lacks native zstd; serve br/gzip when zstd unavailable.
      const fallback = codec === "zstd" ? "br" : codec;
      if (!fallback) return appendVary(res);
      return encode(res, fallback);
    }
    return encode(res, codec);
  },
};

function encode(res, codec) {
  const stream = res.body.pipeThrough(new CompressionStream(codec === "br" ? "deflate" : codec));
  const out = new Response(stream, res);
  out.headers.set("content-encoding", codec);
  out.headers.delete("content-length"); // length changes after compression
  appendVaryHeaders(out);
  return out;
}

function appendVary(res) {
  const out = new Response(res.body, res);
  appendVaryHeaders(out);
  return out;
}

function appendVaryHeaders(out) {
  const vary = out.headers.get("vary");
  out.headers.set("vary", vary ? `${vary}, Accept-Encoding` : "Accept-Encoding");
}

Two side effects matter. First, you must delete Content-Length because the compressed body length differs from the origin’s declared length; leaving a stale value causes truncated or hung responses. Second, the Workers runtime CompressionStream natively supports gzip and deflate but not raw Brotli or Zstandard in all releases, so check your compatibility date – where br/zstd streams are unavailable, prefer Cloudflare’s built-in Brotli from Step 1 and reserve the Worker for header logic and routing. The same header-rewriting discipline applies whenever you are modifying request and response headers at the edge.

Deploy:

wrangler deploy
# Total Upload: 2.41 KiB / gzip: 1.02 KiB
# Published edge-compress (1.8 sec)

Step 4: Exclude already-compressed content

Re-compressing media wastes edge CPU and frequently produces a body larger than the original. The matrix below is the policy you want, whether enforced by the platform’s content-type list or your Worker regex.

Content type Codec at edge Reason
text/html, text/css, application/javascript br / zstd Highly compressible text
application/json, image/svg+xml br / zstd Text-based, large savings
image/jpeg, image/png, image/webp, image/avif identity Already entropy-coded
video/mp4, audio/*, font/woff2 identity Pre-compressed containers
application/gzip, anything with Content-Encoding identity Double compression breaks clients

Keep the compressible set narrow and explicit. WOFF2 fonts and AVIF/WebP images already use internal compression, so a second pass adds latency for zero benefit.

Verification

Request the asset advertising both modern codecs and read back the Content-Encoding and Vary headers:

curl -H "Accept-Encoding: br, zstd, gzip" -sI https://www.example.com/app.js

Expected (Brotli negotiated):

HTTP/2 200
content-type: application/javascript
content-encoding: br
vary: Accept-Encoding
cf-cache-status: HIT

Confirm the edge respects identity clients – a request without the header must come back uncompressed and must not carry a Content-Encoding:

curl -H "Accept-Encoding: identity" -sI https://www.example.com/app.js | grep -i 'content-encoding'
# (no output = correct: served identity)

Measure the real wire savings by comparing compressed and identity byte counts:

for ae in "br" "gzip" "identity"; do
  bytes=$(curl -s -H "Accept-Encoding: $ae" https://www.example.com/app.js | wc -c)
  printf "%-9s %s bytes\n" "$ae" "$bytes"
done
# br        18432 bytes
# gzip      21765 bytes
# identity  92140 bytes

Tail the Worker if you used Step 3, to confirm the codec branch you expect is firing:

wrangler tail --format pretty
# GET /app.js  ->  encode=br  type=application/javascript

Troubleshooting

Responses come back identity even with Accept-Encoding: br. The most common cause is a DNS-only record, so the request never hit the proxy – verify the record is proxied as covered in the edge compression and asset optimization guide. On CloudFront, check that the cache behavior has compress = true and that the attached cache policy enables enable_accept_encoding_brotli; compression silently no-ops if the header is not part of the cache key. Also confirm the content type is on the compressible list and the body is over ~1 KB.

curl reports ERR_CONTENT_LENGTH_MISMATCH or the body is truncated. A Content-Length header survived compression. In the Worker you must headers.delete("content-length") after wrapping the stream, since the post-compression length differs from the origin value. Re-run curl -sI and confirm content-length is absent and transfer-encoding: chunked or HTTP/2 framing handles the body.

Dynamic responses are uncompressed while static assets compress fine. Many CDNs only compress cacheable objects. For dynamic HTML or API JSON, set an explicit compressible content type and, on Cloudflare, route through a Worker that compresses regardless of cache status. Verify the origin is not already sending Content-Encoding, which makes the edge skip the response.

A shared cache serves a br body to a client that only sent gzip. Your Vary: Accept-Encoding header is missing or was stripped downstream. Confirm it is present at every hop with curl -sI, and ensure no later transform removes it. Without Vary, a proxy stores one encoded variant and replays it to mismatched clients, producing decode errors.

zstd is never selected even though Chrome supports it. Zstandard is only offered over HTTPS in a secure context and many tools omit it. Confirm the client actually sent zstd in Accept-Encoding, that your edge runtime can emit it (most cannot via CompressionStream yet), and that you have not forced a br-only path. When in doubt, fall back to Brotli, which is universally supported.

Compressed images are larger than the originals. You are compressing already-compressed media. Tighten the compressible content-type set so image/*, video/*, and font/woff2 route to identity, per the exclusion matrix above.

Back to Edge Compression & Asset Optimization

Frequently Asked Questions

Should I compress at the edge or at the origin? Compress at the edge for cacheable assets so one compressed copy serves many clients and your origin stays simple; keep origin compression as a fallback for non-proxied paths.

Why is Vary: Accept-Encoding mandatory? Shared caches store one body per cache key, so without Vary a proxy can hand a Brotli body to a gzip-only client and trigger decode errors; Vary forces separate variants per encoding.

Is Zstandard worth enabling over Brotli today? Zstd compresses and decompresses faster at similar ratios but is only advertised by recent Chromium browsers over HTTPS, so treat it as an opportunistic upgrade with Brotli and gzip as fallbacks.

Does compressing already-gzipped or image content help? No. Media and pre-compressed payloads are already entropy-coded, so a second pass burns CPU and often grows the body; route those content types to identity.

How do I confirm clients actually receive the compressed body? Run curl with an Accept-Encoding header and read the response Content-Encoding, then compare byte counts across br, gzip, and identity to see the real wire savings.