Adding a Device Class to the Cache Key
Problem Statement
The origin serves different markup to phones and desktops. To keep that correct, the cache must distinguish them — but the obvious mechanism, Vary: User-Agent, gives every browser build its own entry and reduces the hit ratio to near zero. The dimension is real; the header expressing it has the wrong cardinality.
Prerequisite Concepts
- How CDN cache keys are generated — the components a key is composed from.
- Mapping Vary headers to edge routing — why cardinality decides whether a dimension is affordable.
- Cache key normalization and query strings — the normalization pipeline this step joins.
Step-by-Step Resolution
Step 1 — Confirm the response really differs
Adaptive markup is less common than it used to be. If the site is responsive, the bodies may be identical and the dimension unnecessary:
UA_M='Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15'
UA_D='Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/126.0'
curl -s -H "User-Agent: $UA_M" https://example.com/ | sha256sum
curl -s -H "User-Agent: $UA_D" https://example.com/ | sha256sum
Identical digests mean no device dimension is needed, and adding one would multiply storage for nothing.
Step 2 — Derive a bucket at the edge
The cardinality problem is solved by classifying before the key is composed:
sub vcl_recv {
if (req.http.Sec-CH-UA-Mobile == "?1") {
set req.http.X-Device = "mobile";
} elsif (req.http.User-Agent ~ "(?i)(iphone|android.*mobile|windows phone)") {
set req.http.X-Device = "mobile";
} elsif (req.http.User-Agent ~ "(?i)(ipad|android(?!.*mobile)|tablet)") {
set req.http.X-Device = "tablet";
} else {
set req.http.X-Device = "desktop";
}
unset req.http.User-Agent;
}
Client hints are checked first because Sec-CH-UA-Mobile is already a two-state value and needs no parsing; the User-Agent branches are the fallback for clients that do not send hints.
Step 3 — Key on the bucket
sub vcl_hash {
hash_data(req.http.X-Device);
}
The raw header has already been removed, so it cannot influence the key even indirectly.
Step 4 — Declare the dimension downstream
Intermediaries between the edge and the browser need to key the same way, which means the origin must advertise the dimension it actually varies on:
HTTP/2 200
Vary: X-Device, Accept-Encoding
Cache-Control: public, s-maxage=3600
Advertising X-Device rather than User-Agent is what keeps downstream caches at three entries instead of thousands.
Step 5 — Verify both correctness and cardinality
Two checks, because each catches a different failure:
# Correctness: each class gets its own markup
curl -s -H "User-Agent: $UA_M" https://example.com/ | grep -c 'class="mobile-nav"'
curl -s -H "User-Agent: $UA_D" https://example.com/ | grep -c 'class="desktop-nav"'
# Cardinality: two different phones share one entry
curl -sI -H "User-Agent: $UA_M" https://example.com/ | grep -i '^age'
curl -sI -H 'User-Agent: Mozilla/5.0 (Linux; Android 14; Pixel 8) Mobile' \
https://example.com/ | grep -i '^age'
The second pair must both report a climbing Age. A miss on the second means the classifier is producing more buckets than intended.
Step 6 — Prefer removing the dimension entirely
Every device class multiplies stored entries across every URL and every encoding variant, so the cheapest device dimension is the one you do not need. Before investing in a classifier, it is worth asking whether the markup difference can be moved out of the response.
Two techniques cover most cases. The first is to serve one markup and let CSS decide the layout, which is what responsive design already does for the majority of sites; the device dimension then disappears from the cache entirely. The second is to keep a single cached shell and load the device-specific fragment separately, so only the small fragment carries the dimension while the large document stays shared.
Where the difference is genuinely structural — a different navigation component, a different image set, a different set of scripts — the classifier is the right answer and three entries per URL is a reasonable price. The point of asking first is that the price is paid on every URL, every day, whereas the markup difference is often a decision nobody has revisited since the site was built.
Expected Output / Verification
A correct configuration produces three warm entries per URL, each serving the markup its class expects, with a hit ratio close to the site average rather than a fraction of it.
mobile HIT Age: 2841 contains mobile-nav
tablet HIT Age: 1902 contains mobile-nav
desktop HIT Age: 3410 contains desktop-nav
Edge Cases
- Bots and crawlers. A search crawler’s user agent may not match any pattern and lands in
desktop, which is usually correct — but confirm it, because serving mobile markup to a desktop crawler can affect how the page is evaluated. - Client hints missing on first request. Hints are often absent until the server requests them, so the User-Agent fallback carries the first navigation. Both paths must produce the same bucket or the first request lands in the wrong entry.
- A fourth class added later. Every added class multiplies entries across every URL and every encoding variant. Add one only when the markup genuinely differs.
- The raw header reaching the origin. Removing it from the key does not remove it from the forwarded request. If the origin also branches on the raw string, its output will not match the bucket the response is stored under.
- Device detection libraries at the origin. If the origin classifies independently, its classification must agree with the edge’s exactly, or the stored response contradicts its key.
- Preview or emulation modes. A device-emulation query parameter used for testing will not change the bucket unless it is wired into the classifier, so previews will show the wrong markup from cache.
Frequently Asked Questions
Why not just use Vary: User-Agent?
Because real traffic contains thousands of distinct User-Agent strings, so every browser build creates its own cold entry. Hit ratio collapses while the response bodies remain identical across almost all of them.
How many device classes are enough?
Two or three. Mobile and desktop covers most responsive sites; adding tablet is worthwhile only if the markup genuinely differs. Each additional class multiplies the stored entries for every URL.
Should the derived header be sent to the browser?
It should be listed in Vary so downstream caches key consistently, which means it appears in the response. Sending the value itself is optional and mainly useful for debugging.
Is client-hint based detection better?
It is cleaner where supported, because Sec-CH-UA-Mobile is already a low-cardinality value rather than a string to be parsed. It needs a fallback for clients that do not send hints, so most deployments use both.
Related
- Mapping Vary headers to edge routing covers the cardinality reasoning this applies to a specific dimension.
- Using Vary: Accept-Encoding without fragmenting cache is the same bucketing technique applied to compression.
- Including or ignoring cookies in the cache key handles the other high-cardinality dimension sites are tempted to key on.
Back to How CDN Cache Keys Are Generated