Cache Key Normalization and Query Strings

TL;DR: A cache key is a string, and two requests share a stored response only when their keys match exactly. Left at its default, that string includes the query as written — so ?page=2&q=boots and ?q=boots&page=2 are two entries, and a campaign tag turns one page into thousands. Normalization is the process of reducing all the URLs that mean the same thing to one key, without collapsing URLs that genuinely mean different things.

Requested:  /search?utm_source=newsletter&page=2&q=boots&fbclid=IwAR3x
Key:        /search?page=2&q=boots

Mechanism and RFC Alignment

RFC 9111 §4 defines the cache key loosely: a stored response is selected by the request method and target URI, plus any headers named in Vary. It says nothing about how the target URI should be compared, because at the specification level a URI is a URI. Every practical decision about query strings — sorting, stripping, allow-listing — lives entirely in vendor configuration, which is why key behaviour is the least portable part of a CDN setup, as noted in how CDN cache keys are generated.

The default in almost every implementation is byte comparison of the full request target. That default is defensible: the edge has no way to know which parameters affect the response, and treating two different URLs as equivalent risks serving the wrong content. But it means all accidental variance in the URL becomes cache fragmentation.

From request URL to composed keyEach normalization step removes a source of accidental variance, and the composed key is what the edge actually looks up.Request URLas sent by the clientparseStrip trackingutm, fbclid, gcliddropAllow-listkeep only meaningful paramsfilterSortstable orderinghashCache keyone entry per meaning
Four transformations between the URL and the stored entry

Three sources of accidental variance dominate real traffic. Campaign and click identifiers are the largest by far: every distinct utm_content value, every fbclid, every gclid produces a unique cold entry for a page whose body is identical. Parameter ordering comes second, and is usually introduced by client code building URLs from an unordered map. Case and trailing-slash differences come third, generated by inconsistent internal links and by external sites linking with their own conventions.

Scope and Precedence

Normalization applies before anything else in the cache pipeline. Key composition happens first; only then does the edge look for a stored entry, and only then is freshness considered. A key mismatch is therefore not a caching failure that a directive can fix — the directives are never reached.

Where key composition sits in the request pathThe key is built before lookup, and lookup precedes every freshness and validation decision, so a key problem masks everything downstream.HAPPENS FIRST1Normalize the URLstrip, allow-list, sort2Add Vary dimensionsone entry per variant3Look up the entryhit or miss decided here4Evaluate freshnessmax-age and s-maxage5Revalidate if staleconditional requestHAPPENS LAST
Key first, then lookup, then everything else

The interaction with Vary is additive and often underestimated. Key normalization reduces variance along the URL axis; Vary adds variance along the header axis. A URL reduced to a single clean key can still fragment into dozens of entries if a high-cardinality header is varied on, which is why the two problems should be measured together — see mapping Vary headers to edge routing.

Precedence between normalization rules matters too. Stripping must happen before sorting, or the sort will order parameters that are about to be removed. Allow-listing must happen before both, or an unknown parameter can survive by sorting ahead of the filter.

Implementation Patterns

Allow-list rather than deny-list. A deny-list of known tracking parameters is permanently out of date: the next marketing tool introduces a new one, and the day it launches your hit ratio drops. An allow-list of the parameters your application actually reads is stable, because it changes only when you change the application.

Strip for the key, not from the URL. Client-side analytics read the campaign parameters from location.search. Redirecting visitors to a cleaned URL loses that data and adds a round trip. Remove the parameters when composing the key and forward the original URL to the origin.

Normalize case on the path, carefully. Lower-casing the path collapses /Products and /products, which is usually correct for a site whose routing is case-insensitive. It is wrong for any path segment that carries a case-sensitive identifier — a base64 token, a content hash. Apply it per route rather than globally.

Decide the trailing slash once and enforce it. /about and /about/ are different keys and usually the same page. Pick one canonical form, redirect the other with a 301, and normalize in the key so that in-flight links do not fragment while the redirect propagates.

Distinct cache keys for one page across a campaign weekEach normalization step removes an entire class of accidental key variance; the campaign parameters account for the overwhelming majority.Raw URL11840keysTracking stripped46keys+ parameters sorted12keys+ allow-listed4keys one per real variantidentical response body for every one of them
Distinct stored entries for a single landing page over seven days

Server and CDN Configuration

Fastly composes the key in VCL, which makes every step explicit:

sub vcl_recv {
    # 1. Drop tracking parameters entirely
    set req.url = querystring.regfilter(req.url,
        "^(utm_[a-z]+|fbclid|gclid|msclkid|mc_[a-z]+|ref)$");

    # 2. Keep only the parameters the application reads
    set req.url = querystring.filter_except(req.url, "q,page,sort,size");

    # 3. Sort what remains so order cannot fragment the key
    set req.url = querystring.sort(req.url);

    # 4. Canonicalise the path
    set req.url = std.tolower(req.url);
}

Nginx has no query-string parser, so normalization is usually done with a map plus an explicit key:

map $args $clean_args {
    default          $args;
    "~^(.*)&?utm_[^&]*=[^&]*(.*)$"  "$1$2";
}

location /search {
    proxy_cache assets;
    # Only the parameters that change the response take part in the key
    proxy_cache_key "$scheme$host$uri?q=$arg_q&page=$arg_page&sort=$arg_sort";
    proxy_pass http://origin;
}

Naming the parameters directly in proxy_cache_key is the clearest form of allow-listing available in Nginx: unlisted parameters cannot influence the key at all, and the parameters appear in a fixed order, which handles sorting as a side effect.

Cloudflare exposes the same decisions through Cache Rules, where a custom cache key can include or exclude named query parameters. The important detail is that the setting is per-rule, so a site with several route families needs several rules rather than one global choice.

Key normalization changes nothing about the Cache-Control line, but it changes how much that line is worth. A long s-maxage on a page whose key fragments across thousands of entries buys nothing, because each entry is requested once. Fixing the key is almost always higher leverage than tuning the lifetime, and the two are frequently confused: a team raises s-maxage, observes no improvement, and concludes the CDN is not caching.

Purging depends on the key as well. A purge by URL targets one key, so a fragmented URL space means a purge that misses most of its variants. This is one of the strongest arguments for surrogate keys and cache tags: tags are attached to the response, so they cover every key variant automatically.

Normalization also interacts with storage. Each accidental key is a full stored copy of the body, so a fragmented key space consumes capacity out of proportion to the content behind it — the mechanism described in cache storage, eviction and capacity.

Verification Workflow

Step 1 — establish the baseline. Request a URL, then request a semantically identical variant and compare the cache status:

curl -sI 'https://example.com/search?q=boots&page=2' | grep -iE 'cf-cache-status|x-cache|^age'
curl -sI 'https://example.com/search?page=2&q=boots' | grep -iE 'cf-cache-status|x-cache|^age'

A MISS on the second request means parameter order is fragmenting the key.

Step 2 — test the tracking-parameter case.

curl -sI 'https://example.com/search?q=boots&page=2&utm_source=newsletter' \
  | grep -iE 'cf-cache-status|^age'

A HIT with a non-zero Age proves the tracking parameter is excluded from the key.

Step 3 — confirm meaningful parameters still separate. Normalization that is too aggressive is worse than none at all, because it serves the wrong content:

curl -s 'https://example.com/search?q=boots&page=1' | md5sum
curl -s 'https://example.com/search?q=boots&page=2' | md5sum

The two digests must differ. Identical digests mean page has been dropped from the key and every visitor is receiving page one.

Step 4 — measure the key space. From access logs, count distinct request targets per path over a day, before and after the change. A path whose distinct-target count falls by two orders of magnitude while its response body count stays at one is the outcome you are looking for.

Building the Allow-List Without Guessing

The hardest part of normalization is not the configuration syntax; it is deciding which parameters belong in the key. Guessing produces one of two failures — a parameter left in that fragments the cache, or a parameter taken out that changes the response — and both are avoidable with a short, mechanical process.

Start from the application rather than from the traffic. Whatever framework serves the route has a definitive list of the query parameters it reads: route definitions, request-schema declarations, or in the worst case a search for the accessor calls. That list is the allow-list. It is short — most routes read between one and four parameters — and it is authoritative in a way that no amount of log analysis can be, because it describes what the code does rather than what clients happen to send.

Then check the traffic for anything the application reads that the list missed. Extract the distinct parameter names from a day of access logs, ranked by request count, and compare against the list. Names appearing in high volume that are not on the list are either tracking parameters, which is expected, or a route the initial survey missed, which is exactly what this step exists to catch. The whole comparison is a couple of log queries and usually takes longer to read than to run.

Finally, prove the boundary in both directions before shipping. For each allow-listed parameter, confirm that changing its value changes the response body — if it does not, it is fragmenting the cache for nothing and belongs off the list. For each excluded parameter, confirm that changing its value does not change the response body. That second check is the one that prevents the serious failure, because a parameter excluded from the key while the origin still varies on it produces a response stored under a key that does not describe it, and the wrong content is then served to everyone who shares that key.

Re-run the comparison whenever routes change. A new filter or sort option added to a search page is a new parameter that must join the allow-list on the same deploy, or the edge will serve unfiltered results to users who asked for filtered ones. Tying the check to the deploy pipeline — a test that asserts the allow-list matches the route’s declared parameters — turns a recurring incident into a build failure, which is a much cheaper place to discover it.

Failure Modes and Gotchas

  1. Ignoring the query string wholesale. The fastest way to a perfect hit ratio and a broken site: every paginated, filtered or sorted URL collapses onto one entry and users receive whichever variant happened to be stored first.

  2. A deny-list that ages out. New tracking parameters appear constantly. A list of known offenders silently stops working the day marketing adopts a new tool.

  3. Normalizing a case-sensitive segment. Lower-casing a path containing a base64 identifier or a content hash produces 404s that only affect a subset of URLs.

  4. Sorting before stripping. The sort orders parameters that are about to be removed, so the resulting key still depends on what was present. Order the steps deliberately.

  5. Forgetting that the origin still sees the original URL. Normalization affects the key, not the forwarded request. An origin that varies its response on a stripped parameter will produce a body that does not match the key it is stored under — the most dangerous failure in this whole area.

  6. Different normalization at the shield and the edge. If the two tiers compose keys differently, the shield cannot serve the edge’s misses and the topology quietly stops working. Apply the rules identically at every tier.

  7. Ignoring the Vary axis. A perfectly normalized URL still fragments if a high-cardinality header is varied on. Measure both axes before concluding the key is clean.

Frequently Asked Questions

Why do two URLs that return the same content miss the cache?

Because the key compares the query string as bytes rather than as a set of parameters. Reordering, an added tracking tag, or a capitalisation difference produces a different key and therefore a separate entry.

Is it safer to ignore the query string entirely?

No — it is the single most dangerous configuration in this area. Every URL under a path shares one entry, so a request for page two can be answered with page one. Allow-list the parameters that genuinely change the response instead.

Should tracking parameters be removed from the key or from the URL?

From the key. Client-side analytics need to read them from the URL, so stripping them for key composition preserves both the measurement and the hit ratio.

Does parameter order really matter to a CDN?

By default it does, because the query string is hashed as written. Sorting before hashing collapses reordered URLs onto one entry and has no downside.

How much hit ratio does normalization actually recover?

It is proportional to the accidental variance in your traffic. Sites with significant campaign traffic commonly recover twenty to forty points, because every campaign tag was previously creating a cold entry for an unchanged page.


Guides in This Topic

Each guide below works through one concrete task or question from this topic, end to end.

Back to CDN Architecture & Edge Routing Strategies