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

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.

From a high-cardinality header to a three-state key dimensionThe raw User-Agent is parsed once at the edge into a small bucket, and only the bucket reaches the cache key.User-Agent4200 distinct valuesrequestEdge classifierhints, then patternsbucketX-Devicemobile, tablet, desktophashCache key3 entries per URL
Thousands of values in, three out

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.

Stored entries per URL, by device dimensionThe dimension itself is affordable; the header expressing it is what decides whether the cache stays warm.Vary: User-Agent4180entriesVary: X-Device (3 classes)3entriesVary: X-Device (2 classes)2entriesNo device dimension1entries responsive markupmultiplied by the encoding dimension in every case
Entries for one URL over 24 hours

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.

Two failure modes, and what each looks likeOver-bucketing costs hit ratio; under-bucketing serves the wrong markup, and the two are distinguished by whether the body or the Age value is wrong.SymptomCauseOver-bucketinglow hit ratio, correct markupclassifier too specificUnder-bucketinghigh hit ratio, wrong markupclassifier too coarseCorrecthigh hit ratio, correct markupthree stable classes
Distinguishing the two ways this goes wrong

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.


Back to How CDN Cache Keys Are Generated