Two different incidents on the same WordPress VIP-style multisite network, months apart, turned out to share one root cause. The first was a bot sending Accept: application/json to an ordinary HTML page, silently tripping a content-negotiation guard and getting served an ads-stripped page — which then got written into the full-page cache under the URL’s normal key and handed to every real visitor after it. The second was a crawler re-fetching URLs with fbclid/utm_* tracking parameters attached, each distinct parameter value minting its own cache entry, guaranteeing a fresh, expensive origin render for a URL variant no one would ever request again.
Different trigger, same shape: a cache key that inherited whatever the request happened to contain, instead of being designed on purpose. This is the piece that should have existed before either incident — what a full-page cache key actually needs to account for, decided in advance rather than discovered by outage.
TL;DR: Two separate incidents on the same network — a bot tripping a content-negotiation guard, a crawler re-fetching tracking-parameter URLs — turned out to share one root cause: a full-page cache key that let the request decide what it means instead of being designed on purpose. Fix: strip known tracking parameters from the cache key, and Vary explicitly on any header that actually changes the response.
Why Is the Default Cache Key Wrong More Often Than It Looks?
A CDN or reverse-proxy cache, out of the box, typically keys on the request URL — path plus query string, sometimes with a handful of headers thrown in by default (Accept-Encoding is common; Accept and User-Agent usually aren’t). That default is a reasonable starting point and a bad permanent choice, because it silently promises something that usually isn’t true: that every distinct URL-plus-query-string produces a meaningfully different response, and that nothing outside the URL affects the response at all. Neither promise holds for long on a real site.
Two questions, asked explicitly rather than assumed, define a correct cache key:
What actually varies the response? If a request header changes what gets rendered — Accept gating content-type-specific logic, a cookie gating a personalized block, a User-Agent sniff — either the cache needs to vary on it explicitly (Vary: Accept) or that header needs to stop affecting the cacheable response at all. Leaving it silently influential, unvaried, is how one request’s behavior leaks into everyone else’s cached copy.
What doesn’t vary the response, but ended up in the key anyway? Query strings are the usual offender. ?fbclid=abc123 and no query string at all typically render byte-for-byte identical HTML — the parameter exists for an analytics platform on the client side, not for anything server-side WordPress does with it. If the cache key includes it anyway, every distinct value is a guaranteed miss for content that was never actually different.
What Does the Fix Look Like in Practice?
At the edge/CDN layer, both problems are solved the same way: normalize the request before it becomes a cache key, not after something has already gone wrong downstream.
# Illustrative VCL-style pseudocode — strip known tracking
# parameters before the request reaches the cache key.
sub vcl_recv {
set req.url = querystring.remove(
req.url,
"fbclid, gclid, utm_source, utm_medium, utm_campaign, utm_term, utm_content"
);
}
# And the mirror image: if a header genuinely changes the
# response, say so explicitly instead of leaving it unvaried.
Vary: Accept
Neither line is complicated. The reason they weren’t there before either incident isn’t that the fix is hard — it’s that cache-key design tends to happen once, early, by whoever set up the CDN, and then gets treated as settled infrastructure rather than something that needs revisiting every time a new header-sensitive feature or a new marketing tracking parameter shows up.
What’s the Checklist to Apply Before the Next Incident?
- List every parameter your marketing/analytics tooling appends to URLs (
utm_*,fbclid,gclid,mc_cid/mc_eidfor email campaigns, and anything platform-specific) and strip all of them from the cache key, deliberately, in one place — not per-endpoint. - List every request header your application logic actually branches on —
Accept,Accept-Language, cookies used for anything beyond session identity — and eitherVaryon it correctly or refactor the logic so it stops depending on a header that ordinary browser and bot traffic can’t be trusted to send consistently. - Assume any of this will eventually get hit by traffic you didn’t design for. The trigger in both real incidents wasn’t a browser; it was automated traffic — a bot, a crawler — sending a request shape a human visitor rarely would. Cache-key design has to account for the request space a server actually receives, not the one a QA checklist covers.
- Apply the fix cluster-wide, not per-site. Both incidents affected one site visibly and left siblings on the same platform exposed to the identical defect, just without (yet) a triggering traffic pattern. A cache-key correction belongs at the shared edge config, once, not patched site-by-site as each one happens to get unlucky.
What’s the Generalizable Lesson Here?
- A cache key is a claim about what makes two requests "the same response." Every time that claim is wrong in either direction — treating identical requests as different, or different requests as identical — you get a bug, just in opposite directions (wasted cache misses, or a poisoned cache serving one request’s response to everyone).
- Query-string tracking parameters are the single most common source of false cache misses on any site that runs ads or email campaigns. Strip the known ones centrally; don’t wait for a crawler to find the gap.
- Any header your application logic actually branches on needs an explicit
Vary, or it needs to stop being load-bearing for a cacheable response. There’s no safe middle ground. - Design this once, at the edge, before a real incident forces the question. The fix is a handful of lines either way — the cost was never the code, it was treating the cache key as fixed infrastructure instead of a decision that needs revisiting.