Surrogate Keys and Cache Tags
TL;DR: A surrogate key (Fastly, Varnish) or cache tag (Cloudflare) is a piece of metadata the origin attaches to a cached response, independent of its URL. The CDN indexes objects by every tag they carry. Purging a tag evicts every object registered under it — one product update can invalidate dozens of rendered URLs in a single API call, without the application ever having to enumerate them.
Quick-reference header block for a product detail page that must be purgeable by product ID and by category:
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Surrogate-Key: product-9472 category-footwear
Mechanism & RFC Alignment
RFC 9111 defines exactly two ways a stored response stops being usable: it goes stale under the freshness_lifetime calculation, or a validator fails on conditional revalidation. Neither mechanism gives an application a way to say “this specific set of cached objects is now wrong, evict them immediately” without waiting for a TTL to lapse. RFC 9111 §4.4 explicitly leaves room for exactly this: caches may be told to invalidate stored responses through means outside the freshness model, and vendor CDNs fill that gap with tagging.
The mechanism has three moving parts:
- Tag emission — the origin sets a vendor header listing every tag that applies to a response:
Surrogate-Key(Fastly, Varnish via the xkey module),Cache-Tag(Cloudflare), or occasionally a custom header translated into one of these at the edge. A response can carry many tags at once. - Inverted index construction — the edge node stores the object under its normal cache key, and separately records each tag value in a tag → cache-key index local to that node or propagated across the CDN’s control plane.
- Tag purge — a purge API call targeting a tag name looks up every cache key registered under it and evicts them as a batch, regardless of how many distinct URLs,
Varyvariants, or query-string permutations those keys represent.
This is a different operation from the tag-based purge dispatch and propagation that fires when content changes — this page is about the tag itself: what to put in it, how many you can attach, and how tagging headers relate to the TTL headers a CDN also reads.
A tag is meaningless without storage. If a response carries no-store or lacks public, the CDN never stores the object, so it never builds a tag index entry — the tag header is discarded unread. Tagging is strictly additive on top of a response that is already eligible for shared-cache storage.
Scope & Precedence
Tags interact with, but never replace, the standard freshness and storage rules:
| Priority | Mechanism | Effect on tagging |
|---|---|---|
| 1 | no-store / lack of public |
CDN never stores the object; tag header is ignored entirely |
| 2 | Explicit tag purge | Forces immediate eviction regardless of remaining TTL |
| 3 | s-maxage / Surrogate-Control TTL |
Governs passive expiry; tags govern active eviction |
| 4 | max-age (browser) |
Unaffected by tag purges — browsers have no concept of tags |
| 5 | Heuristic freshness | Rarely paired with tagging in practice; explicit TTL is standard |
Tags are a shared-cache concept only. Browsers never see or act on Surrogate-Key or Cache-Tag — strip them at the CDN egress layer so they don’t leak to clients. A browser holding a locally cached copy under its own max-age will keep serving that copy after a CDN-side tag purge, until its own TTL expires or the page is reloaded past that window. Keep browser TTLs short for content you expect to purge by tag.
Vary also affects tag scope. A response with Vary: Accept-Encoding produces one cache entry per encoding; if all variants carry the same tag, a single purge evicts every variant together. See mapping Vary headers to edge routing for how variant fragmentation multiplies the number of keys a tag can point at.
Architecture: One Update, Many Purged URLs
Implementation Patterns
Pattern 1: Per-entity tagging
Tag the single logical record a response is built from:
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Surrogate-Key: product-9472
Every rendered surface that reads product 9472 — its detail page, cart line item, and any API fragment — carries this tag. When only that record changes, purge product-9472 and nothing else is touched. This is the narrowest possible blast radius and should be the default for record-scoped content.
Pattern 2: Per-collection tagging
Tag the grouping a response belongs to, independent of any single record:
Cache-Control: public, s-maxage=3600
Surrogate-Key: category-footwear page-type-listing
Use collection tags when a change to the grouping itself — a reordered category, a new listing template, a re-ranked search algorithm — should invalidate every page in that grouping without needing per-record tags at all.
Pattern 3: Hybrid — entity and collection together
Most production catalogs emit both scopes on the same response so either level of change has a clean purge target:
Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Surrogate-Key: product-9472 category-footwear brand-acme tenant-eu
A price change purges product-9472 alone. A category re-merchandising event purges category-footwear, which evicts every product page in that category simultaneously — including this one — without the catalog service needing to enumerate product IDs.
Pattern 4: API responses — resource type plus resource instance
Cache-Control: public, s-maxage=60, stale-while-revalidate=30
Surrogate-Key: resource-orders resource-orders-user-4821
Content-Type: application/json
The resource-orders tag supports a bulk purge (a schema migration, a bug fix affecting every order response). The instance-level tag supports a targeted purge after a single order mutation. See combining Cache-Control directives safely for how stale-while-revalidate interacts with the short TTL here.
Pattern 5: What NOT to tag
Cache-Control: public, max-age=31536000, immutable
Content-hashed static assets (/app.a3f9b2.js) never need tags — the filename itself is the invalidation mechanism. Tagging every static asset with a build-ID tag just to purge them in bulk during a deploy adds index overhead for no benefit; a new hash in the URL already forces a miss. Reserve tagging for mutable, content-addressed responses, and never tag private or no-store responses — the CDN discards the tag header on those paths since it never stores the object.
Server and CDN Configuration
Fastly — VCL, emitting tags from an origin header
Fastly reads Surrogate-Key directly off the origin response, but you can also compute or rewrite it at the edge in vcl_fetch before the object is stored:
sub vcl_fetch {
# Origin already sets Surrogate-Key: product-9472 category-footwear
# Add a collection-wide tag at the edge without touching app code
if (beresp.http.Content-Type ~ "text/html") {
set beresp.http.Surrogate-Key = beresp.http.Surrogate-Key " page-type-html";
}
return (deliver);
}
sub vcl_deliver {
# Never let the tag header reach the client
unset resp.http.Surrogate-Key;
}
Purge a single tag, or use soft purge to mark it stale instead of evicting it outright:
curl -X POST "https://api.fastly.com/service/$SERVICE_ID/purge/product-9472" \
-H "Fastly-Key: $FASTLY_TOKEN" \
-H "Fastly-Soft-Purge: 1"
Cloudflare — Cache-Tag emission and stripping (Enterprise)
Cloudflare’s Cache-Tag requires an Enterprise plan and a comma-delimited list, capped at 1,000 tags and a 16KB header size per object:
Cache-Control: public, s-maxage=86400
Cache-Tag: product-9472,category-footwear,brand-acme
Configure a Transform Rule in the dashboard (Rules → Transform Rules → Modify Response Header) to strip Cache-Tag before it reaches the client, since Cloudflare does not strip it automatically:
When: hostname equals "www.example.com"
Then: Remove header "Cache-Tag"
Purge by tag through the API:
curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tags": ["product-9472"]}'
Nginx — emitting tag headers at the origin
If your application already computes tags as an internal header, translate it to the vendor-specific name before the response leaves the origin, and strip it a second time as a safety net:
location /products/ {
proxy_pass http://app_upstream;
# App sets X-App-Tags: product-9472 category-footwear
# Relay it to the CDN under the vendor header name
proxy_pass_header X-App-Tags;
add_header Surrogate-Key $upstream_http_x_app_tags always;
# Belt-and-suspenders: strip the internal header from client responses
proxy_hide_header X-App-Tags;
}
For Varnish-fronted origins using the xkey module, the equivalent VCL uses xkey.purge() server-side rather than an external purge API call — useful when the invalidation trigger originates inside the same infrastructure tier as the cache.
Interaction with Related Directives
Surrogate-Control vs Surrogate-Key/Cache-Tag — these solve unrelated problems and are commonly confused because they share a vendor origin. Surrogate-Control sets a CDN-specific TTL that overrides what s-maxage or max-age communicate to browsers, letting you decouple edge freshness from client freshness entirely. Surrogate-Key and Cache-Tag carry no TTL information at all — they are pure lookup metadata for eviction. A response can use one, both, or neither independently:
Cache-Control: max-age=60
Surrogate-Control: max-age=86400
Surrogate-Key: product-9472
Here the browser treats the response as fresh for 60 seconds while the CDN holds it for 24 hours — and either party can still force an immediate edge eviction via the product-9472 tag regardless of that 24-hour window.
s-maxage and tags — s-maxage (or Surrogate-Control) sets the passive TTL a tag purge can pre-empt. Without an explicit shared-cache TTL, some CDNs fall back to heuristic freshness and may not build a tag index entry at all. Always pair tags with an explicit TTL directive.
stale-while-revalidate and tags — a hard purge removes the entry outright, leaving nothing for stale-while-revalidate to serve; the next request is a synchronous origin fetch. Fastly’s soft purge instead marks the tagged entries stale, preserving the stale-serving grace period during background revalidation.
Vary and tag scope — each Vary dimension multiplies the number of cache keys a tag’s index must track. Tag every variant identically so a single purge clears the full set, rather than leaving stale variants behind.
no-store and tagging — a response marked no-store is never stored, so any tag header on it is discarded unread. Audit middleware that defensively adds no-store to authenticated paths; it silently disables tagging on any response it touches.
Verification Workflow
Step 1 — Confirm the tag header reaches the CDN
curl -sI -H "Host: yourdomain.com" https://origin.internal/products/9472 \
| grep -iE 'surrogate-key|cache-tag|cache-control'
Both the TTL directive and the tag header must be present. If the tag header is missing at the origin, no index entry will ever be built and purges will silently no-op.
Step 2 — Confirm the tag header does NOT reach the client
curl -sI https://yourdomain.com/products/9472 | grep -iE 'surrogate-key|cache-tag'
This should return nothing. A tag header leaking to clients indicates a missing egress strip rule (Transform Rule, vcl_deliver, or proxy_hide_header).
Step 3 — Warm two URLs that share one tag
curl -sI https://yourdomain.com/products/9472 | grep -iE 'x-cache|cf-cache-status|age'
curl -sI https://yourdomain.com/category/footwear | grep -iE 'x-cache|cf-cache-status|age'
Both should show a HIT with a positive Age on the second request to each.
Step 4 — Purge the shared tag and confirm both evict together
curl -sX POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \
-d '{"tags": ["category-footwear"]}'
curl -sI https://yourdomain.com/products/9472 | grep -iE 'age|cf-cache-status'
curl -sI https://yourdomain.com/category/footwear | grep -iE 'age|cf-cache-status'
Both URLs should show Age: 0 immediately after the purge. If only one evicts, the missing URL never received the shared tag at origin — check Step 1 for that path specifically.
Step 5 — Check DevTools for tag leakage on a live page
In Chrome DevTools’ Network tab, inspect the Response Headers panel for the live page. Surrogate-Key or Cache-Tag should never be visible here; if it is, egress stripping is misconfigured on that route.
Failure Modes & Gotchas
- Tags present on some code paths, absent on others — a common gap: the slow render path emits tags, but an application-level cache or fallback template skips the tagging middleware. Audit every code path that can produce a
200, not just the primary one. - Cardinality explosion from request-scoped tags — tagging by session ID or request timestamp instead of a stable entity ID creates one index entry per request. Cloudflare Enterprise caps
Cache-Tagat 1,000 tags per object and 16KB total header size; Fastly capsSurrogate-Keyat 1,024 per object. Excess tags are dropped silently, not rejected with an error. - Delimiter mismatch across vendors — Cloudflare’s
Cache-Tagis comma-delimited; Fastly and Varnish’sSurrogate-Key/xkeyare space-delimited. SendingCache-Tag: product-9472 category-footwearto Cloudflare creates one tag containing a literal space, not two tags. - Confusing
Surrogate-Controlwith a tagging header — because both are vendor extensions with “Surrogate” in the name, teams sometimes assume settingSurrogate-Controlalso tags the object. It does not; it only sets a TTL. Tagging requires the separateSurrogate-KeyorCache-Tagheader. - Tag index not propagated through the shield tier — when origin shielding is active, confirm your CDN propagates tag indexes from the shield to every edge PoP, and that purge calls reach both tiers.
- Tagging
no-storeorprivateresponses — the tag header is accepted by the origin but discarded by the CDN, since nothing gets stored. This produces purge calls that return success but evict nothing, because there was never anything to evict. - Over-broad collection tags used for record-level changes — tagging every response with only a single catalog-wide tag forces every purge to have full blast radius. Keep per-entity tags available even when collection tags exist, so routine record edits don’t force a full-category re-fetch storm.
Frequently Asked Questions
What’s the difference between a cache key and a surrogate key?
A cache key is the primary, one-to-one lookup identifier for a stored response — built from the URL and any Vary-listed headers. A surrogate key (or cache tag) is a secondary, many-to-many label: many cache keys can share the same tag, and one purge against that tag evicts all of them.
How should I design my tag taxonomy — per-entity, per-collection, or both?
Use per-entity tags (product-9472) for the narrowest possible purge blast radius, and per-collection tags (category-footwear) for changes that legitimately affect an entire grouping. Most production systems emit both on the same response — see Pattern 3 above — so either scope of change has a clean, direct purge target without over- or under-purging.
How many tags can I attach to one response before hitting a limit?
Cloudflare Enterprise caps Cache-Tag at 1,000 tags per object with a 16KB total header size limit; Fastly caps Surrogate-Key at 1,024 keys per object. Both platforms silently drop excess tags rather than erroring, so design around stable logical entities rather than high-cardinality, per-request values.
What is Surrogate-Control and how does it relate to tagging?
Surrogate-Control sets a CDN-specific TTL, independent of the Cache-Control value browsers see — it is a freshness mechanism, not a tagging mechanism. Surrogate-Key and Cache-Tag carry no TTL at all; they exist purely so the CDN can evict a set of objects by metadata. The two headers are frequently used together but are functionally unrelated.
Can I mix per-entity and per-collection tags safely?
Yes — this is the recommended default. List both tag types in the same header value; each tag builds an independent index entry at the edge, so purging one has no effect on objects registered only under the other.
Related
- How CDN Cache Keys Are Generated — the primary lookup identifier that a tag index sits alongside; understanding key construction clarifies exactly which stored objects a tag purge can reach.
- Tag-Based Cache Invalidation Patterns — the purge dispatch and propagation mechanics that fire once a tag has been designed and attached.
- Mastering max-age and s-maxage Directives — the TTL directives a tag purge pre-empts, and the split browser/edge freshness model tags sit on top of.
- Public vs Private Cache Scope —
publicis a prerequisite for shared-cache storage; without it no tag index entry is ever created. - Serving Stale Content with stale-while-revalidate — how soft purges interact with the stale-serving grace period instead of evicting outright.