Cache Key Normalization and the Vary Header at the Edge

This guide is part of Edge Caching & CDN Integration. It covers how to derive a deterministic cache key at the edge so that semantically identical requests collapse onto a single cached entry, and how to use the Vary header to split that entry only along axes that genuinely change the response.

A cache is only as useful as its key. If two requests that should return the same bytes compute different keys, you get a cache miss, an origin fetch, and a duplicate entry consuming storage. If two requests that should return different bytes compute the same key, you serve the wrong content — a stale or cross-user response. Cache key normalization is the discipline of mapping the messy space of inbound URLs and headers onto a small, stable set of canonical keys. Get it wrong and you either fragment the cache into uselessness or poison it.

Why edge runtimes force the issue

At the origin, a heavyweight CDN or reverse proxy often normalizes keys for you with declarative config. At the edge you are writing the policy engine yourself, inside a V8 isolate with a tight CPU budget and only Web APIs available. There is no crypto Node module, no path helper, no in-process Redis. You normalize URLs with URL/URLSearchParams, hash with crypto.subtle, and read headers off a Headers object. Every branch of that logic runs on the request hot path, so it must be cheap, allocation-light, and — above all — deterministic across every Point of Presence (PoP) in the network.

Determinism is the hard requirement. The same logical request can arrive with parameters in different orders (?b=2&a=1 vs ?a=1&b=2), with tracking junk appended (?utm_source=...), with inconsistent casing in the path, with or without a trailing slash, and with volatile headers like Date or User-Agent that differ on every hit. A naive key built from the raw URL string treats all of these as distinct. The job of normalization is to throw away everything that does not affect the response body and keep everything that does.

Cache key normalization and Vary Three syntactically different URLs collapse into one normalized key, which then splits into two variants based on Accept-Encoding via the Vary header. /Products/?b=2&a=1&utm_source=x /products?a=1&b=2 /products/?a=1&b=2&fbclid=y normalize() allowlist · sort · case key: /products?a=1&b=2 + Vary: Accept-Encoding …|br …|gzip
Normalization collapses syntactically different requests onto one key; Vary splits that key only along axes that change the bytes.

Architecture overview

A cache key derivation function is a pure transformation: (Request, Config) → string. It reads the request URL and a configured set of headers, applies a fixed sequence of normalization passes, and emits a canonical string. The same function runs identically on read (to look up an entry) and on write (to store one), so any divergence between the two paths silently breaks the cache.

The normalization sequence, in order:

  1. Lowercase the host and path — URLs are case-insensitive in host and (by convention for most apps) path, but cache lookups are byte-exact. Normalize early.
  2. Collapse trailing slashes — decide on one canonical form (/products or /products/) and enforce it everywhere, matching your origin’s redirect behavior.
  3. Allowlist query parameters — keep only the params that change the response; drop everything else, especially tracking parameters.
  4. Sort the surviving parameters — by key, then value, so ordering never affects the key.
  5. Drop the fragment#section is client-side only and never reaches the server.
  6. Fold in Vary axes — hash the values of the headers named in your Vary policy into the key so each variant gets its own slot.

The result is a string you hand to the Cache API, or set as a custom cache key on the platform that supports it.

Core implementation

The following is edge-safe TypeScript with no Node built-ins. It uses URL, URLSearchParams, Headers, TextEncoder, and crypto.subtle.

interface CacheKeyConfig {
  /** Query params that materially affect the response. Everything else is dropped. */
  allowedQueryParams: string[];
  /** Headers whose values split the cache (the Vary axes). */
  varyHeaders: string[];
  /** Canonical trailing-slash form: "strip" or "keep". */
  trailingSlash: "strip" | "keep";
}

const TRACKING_PREFIXES = ["utm_", "fbclid", "gclid", "mc_", "_hs"];

function isTracking(key: string): boolean {
  const k = key.toLowerCase();
  return TRACKING_PREFIXES.some((p) => k.startsWith(p) || k === p);
}

async function deriveCacheKey(
  request: Request,
  config: CacheKeyConfig,
): Promise<string> {
  const url = new URL(request.url);

  // 1. Lowercase host + path. Hosts are case-insensitive; treat paths the same.
  url.host = url.host.toLowerCase();
  url.pathname = url.pathname.toLowerCase();

  // 2. Trailing-slash normalization (never touch the root "/").
  if (config.trailingSlash === "strip" && url.pathname.length > 1) {
    url.pathname = url.pathname.replace(/\/+$/, "");
  } else if (config.trailingSlash === "keep" && !url.pathname.endsWith("/")) {
    url.pathname = `${url.pathname}/`;
  }

  // 3 + 4. Allowlist and sort query params deterministically.
  const allowed = new Set(config.allowedQueryParams.map((p) => p.toLowerCase()));
  const pairs: Array<[string, string]> = [];
  for (const [key, value] of url.searchParams.entries()) {
    if (isTracking(key)) continue;
    if (!allowed.has(key.toLowerCase())) continue;
    pairs.push([key, value]);
  }
  pairs.sort((a, b) => (a[0] === b[0] ? a[1].localeCompare(b[1]) : a[0].localeCompare(b[0])));

  const canonicalParams = new URLSearchParams(pairs);
  url.search = canonicalParams.toString();

  // 5. Fragments never reach the server.
  url.hash = "";

  // 6. Fold the Vary axes into the key.
  const varyParts = config.varyHeaders
    .map((h) => h.toLowerCase())
    .sort()
    .map((h) => `${h}=${request.headers.get(h) ?? ""}`)
    .join("|");

  const varyHash = varyParts ? await sha256Hex(varyParts) : "";
  const base = `${url.origin}${url.pathname}${url.search}`;
  return varyHash ? `${base}#v=${varyHash}` : base;
}

async function sha256Hex(input: string): Promise<string> {
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(input),
  );
  // Hex is URL-safe and avoids btoa's "+" / "/" characters.
  return [...new Uint8Array(digest)]
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

A few deliberate choices. The Vary axes are hashed rather than concatenated raw so that a long Accept-Language value does not bloat the key or introduce characters the platform rejects. We emit hex, not base64 — btoa produces +, /, and =, which break custom-key APIs and clutter logs; hex is unambiguously URL-safe. We sort both the Vary header names and the query pairs, because any unsorted input is a non-determinism bug waiting to surface across PoPs.

Reading and writing with the Cache API

The same key drives both halves of the cache transaction. Build a synthetic cache key Request so the Cache API stores under your canonical string instead of the raw inbound URL:

async function withEdgeCache(
  request: Request,
  config: CacheKeyConfig,
  origin: (req: Request) => Promise<Response>,
): Promise<Response> {
  const key = await deriveCacheKey(request, config);
  const cacheKeyRequest = new Request(`https://cache.internal/${encodeURIComponent(key)}`, {
    method: "GET",
  });

  const cache = caches.default ?? (await caches.open("edge"));
  const hit = await cache.match(cacheKeyRequest);
  if (hit) return hit;

  const fresh = await origin(request);
  // Only cache cacheable responses; honor Vary so downstream proxies agree.
  if (fresh.ok && fresh.headers.get("Cache-Control")?.includes("public")) {
    const toStore = new Response(fresh.body, fresh);
    toStore.headers.set("Vary", config.varyHeaders.join(", "));
    await cache.put(cacheKeyRequest, toStore.clone());
    return toStore;
  }
  return fresh;
}

Setting Vary on the stored response matters even when you fold those axes into the key yourself: downstream shared caches and the browser still honor Vary independently, and an incorrect or missing Vary lets them serve a wrong variant. Treat the header and the key as two halves of one policy.

Provider mapping

Each platform exposes a different surface for controlling the cache key. The normalization logic above is portable; what changes is where you apply it.

Provider Cache key control Vary support Notes
Cloudflare Workers cache.put/cache.match with a synthetic-URL Request; or Custom Cache Keys / Cache Rules (cacheKey in fetch options on paid plans) Vary honored; cache key already includes method + URL The Cache API keys on the request URL — drive it with your canonical synthetic URL. Custom Cache Keys can include query allowlists and header values declaratively.
Vercel Edge No custom Cache API write surface in Edge Middleware; control via Cache-Control + Vary on responses and route config Vary honored by the CDN Normalize the URL in middleware with a rewrite to a canonical path so the CDN keys consistently.
Netlify Edge Functions Deno-based; use the Cache API (caches) or Netlify-CDN-Cache-Control plus Cache-Tag/Netlify-Vary Netlify-Vary for fine-grained keying (by query, header, cookie, language) `Netlify-Vary: query=a

On Cloudflare the cleanest provider-agnostic path is the synthetic-key Request shown above; it works identically in wrangler dev and production. On Netlify, Netlify-Vary can replace much of the hand-written allowlist — but you still want the function-level normalization for casing and trailing slashes, which Netlify-Vary does not cover. On Vercel, prefer a request rewrite to a canonical URL so the platform’s own cache keys land consistently.

Control-flow variants

Normalization is rarely unconditional. Three guard shapes recur:

Bypass guard — never cache, never key. Authenticated requests, non-GET methods, and explicit no-store directives short-circuit before key derivation. This is the early-return guard applied to caching:

function isCacheable(request: Request): boolean {
  if (request.method !== "GET") return false;
  if (request.headers.get("Authorization")) return false;
  if (request.headers.get("Cache-Control")?.includes("no-store")) return false;
  return true;
}

Per-route policy — different routes allow different params. A search route keys on q and page; a product route keys on nothing. Resolve the CacheKeyConfig from a route table before deriving the key, so each path declares its own allowlist.

Cookie-derived variant — when the response genuinely differs by a low-cardinality cookie (an A/B bucket, a locale), fold a derived value into the key rather than the raw cookie. See varying edge cache by cookie for the cardinality math and the bucket-extraction pattern.

Framework integration

Next.js App Router. Run normalization in middleware.ts and rewrite to the canonical URL so the platform CDN keys consistently:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export const config = { matcher: ["/products/:path*", "/search"] };

export function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  const before = url.pathname + url.search;
  url.pathname = url.pathname.toLowerCase().replace(/\/+$/, "") || "/";
  // Strip tracking params so the CDN cache key ignores them.
  for (const k of [...url.searchParams.keys()]) {
    if (/^(utm_|fbclid|gclid)/i.test(k)) url.searchParams.delete(k);
  }
  url.searchParams.sort();
  const after = url.pathname + url.search;
  return after !== before ? NextResponse.rewrite(url) : NextResponse.next();
}

Remix. Normalize inside the request handler wrapper before createRequestHandler runs, mutating the Request’s URL into a canonical form so loaders and any Cache-Control you set agree on one key.

SvelteKit. In src/hooks.server.ts, the handle hook receives { event, resolve }. Rewrite event.url to canonical form before calling resolve(event), and set Vary on the returned response.

Debugging workflow

  1. Local. Log the derived key on every request in wrangler dev / netlify dev / next dev. Fire the same logical request three ways (param reorder, added utm_, trailing slash) and assert all three log the same key.
  2. Tracing. Emit the key as a span attribute (cache.key) and a response header (X-Cache-Key) in non-production. Cross-reference with hit/miss to spot fragmentation — many distinct keys for what should be one entry.
  3. Alerting. Track cache hit ratio per route. A sudden ratio drop after a deploy almost always means a key-derivation change made read and write paths disagree. Alert on hit ratio < a per-route floor.

Common pitfalls

Symptom Cause Fix
Hit ratio collapses; storage balloons Tracking params (utm_*, fbclid) leak into the key Strip tracking params and allowlist before keying
Same key serves wrong language/encoding Response varies on a header not folded into the key or Vary Add the header to varyHeaders and to the Vary response header
Read and write paths disagree after deploy Key derivation differs between lookup and store Use one shared deriveCacheKey for both; never inline a second copy
Near-zero hit ratio Volatile header (User-Agent, Date, Cookie) folded into the key Remove volatile axes; vary only on stable, low-cardinality headers
Custom-key API rejects the key base64 key contains +///= Emit hex from crypto.subtle.digest, not btoa
/products and /Products cached twice Path casing not normalized Lowercase url.pathname early in the sequence

Runtime-constraints checklist

  • One shared deriveCacheKey drives both cache.match and
  • Vary axes are low-cardinality, stable headers — never User-Agent, Date, or raw
  • The Vary
  • Cache key hash is hex (URL-safe), produced via
  • Non-GET, authenticated, and no-store

Frequently Asked Questions

Should I include the full query string in the cache key?

No. Including the raw query string fragments the cache, because tracking parameters and parameter ordering create many keys for one logical response. Allowlist only the parameters that change the response body, strip tracking parameters, and sort the survivors so order never matters.

What is the difference between folding a header into the key and setting Vary?

Folding a header value into your computed key controls what your own edge cache stores under. The Vary response header tells downstream shared caches and the browser which request headers may change the response. You generally do both: fold the axis into your key and set a matching Vary so every cache layer agrees.

Why hash header values instead of concatenating them?

Header values like Accept-Language can be long and contain characters that custom-key APIs reject. Hashing them with crypto.subtle produces a short, fixed-length, URL-safe token, and emitting hex avoids the +, /, and = characters that base64 introduces.

Which headers are safe to vary on?

Only low-cardinality, stable headers such as Accept-Encoding or a derived locale. Never vary on User-Agent, Date, or a raw Cookie header; their high cardinality means almost every request gets its own cache entry, collapsing the hit ratio toward zero.

Why do read and write paths sometimes disagree?

Because two separate copies of the key-derivation logic drifted apart, or a deploy changed one path but not the other. Use a single shared deriveCacheKey function for both cache.match and cache.put so the lookup key and the store key are computed identically.