Tag-Based Cache Invalidation Patterns

TL;DR: Attach metadata keys — called surrogate keys, cache tags, or cache labels depending on the CDN — to every cacheable response. When content changes, purge by tag rather than by URL. One API call invalidates every object sharing that tag across all edge PoPs, regardless of how many URLs those objects occupy.

Quick-reference header block for a product page that should be purgeable by product ID and by category:

Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Surrogate-Key: product-9472 category-footwear tenant-eu

Mechanism and RFC 9111 Alignment

Tag-based invalidation decouples resource eviction from URL matching. Without tags, purging stale content after a data update means either purging by exact URL (fragile, requires the application to enumerate every affected path) or purging everything (high blast radius). Neither option scales when a single database record maps to hundreds of rendered pages.

The mechanism operates in three stages:

  1. Tag emission — the origin sets a vendor-specific response header (Surrogate-Key, Cache-Tag, or Surrogate-Control) listing all tag identifiers that apply to this response. Multiple tags are space-delimited.
  2. Index construction — the edge node stores the response under its primary cache key and simultaneously registers each tag in a secondary inverted index: tag → set of cache keys.
  3. Purge dispatch — when a purge API call targets a tag, the edge looks up every cache key in that tag’s index and evicts them all atomically.

RFC 9111 defines cache freshness through max-age, s-maxage, Expires, and conditional validators (ETag, Last-Modified). Tag-based invalidation is a vendor extension that sits above this model: a tag purge forces an immediate cache miss regardless of remaining TTL, consistent with RFC 9111 Section 4.4, which explicitly permits out-of-band cache invalidation.

Tags interact with the standard freshness hierarchy but never replace it. The operative order is:

Priority Mechanism Source
1 (highest) Explicit tag purge CDN purge API
2 no-store / private directives RFC 9111 — prohibit storage
3 s-maxage TTL expiration RFC 9111 — passive expiry
4 max-age TTL expiration RFC 9111 — browser/shared cache
5 Heuristic freshness RFC 9111 Section 4.2.2

A response carrying no-store or private is never stored at the edge. Tags on such a response are meaningless — the CDN never builds the tag index entry because it never stores the object. Conversely, public with s-maxage authorizes shared-cache storage and is the correct baseline for all purgeable content.


Scope and Precedence

Tag purges affect shared caches only. Browsers have no knowledge of Surrogate-Key or Cache-Tag and will not evict their local copy when a CDN purge fires. This has a concrete implication: after a tag purge, the next browser request for a recently-cached URL will hit the CDN (which now returns fresh content), but if the browser’s local max-age has not expired, the user will still see the stale version from disk cache.

The practical split:

  • CDN / reverse proxy: responds immediately to tag purges. s-maxage governs TTL; tag purges override it.
  • Browser private cache: governed by max-age. Unaffected by tag purges. Keep browser TTLs short (60–300 seconds) for mutable content where immediate invalidation matters.
  • Origin shielding layer: the shield node is itself a shared cache and honors tag purges when the CDN propagates them. Verify your CDN propagates purges through the shield tier and does not stop at the edge PoP.

Vary dimensions affect how many distinct cached objects a single tag may cover. A response with Vary: Accept-Encoding generates separate cache entries per encoding — all of them should carry the same tag, and a single tag purge will evict them all. See Mapping Vary Headers to Edge Routing for fragmentation risks.


Diagram: Tag Index Architecture at the Edge

Tag-Based Cache Invalidation Architecture Diagram showing how origin responses tagged with surrogate keys are indexed at the CDN edge, and how a purge API call uses the tag index to evict all matching cache entries. ORIGIN CDN EDGE NODE TAG INDEX (edge-local) HTTP Response Cache-Control: public, s-maxage=86400 Surrogate-Key: prod-9472 cat-footwear Cache Store key: /shoes/running-x1 → response body + headers TTL: 86400s remaining indexes prod-9472 /shoes/running-x1 /cart/9472 /search?id=9472 … + 14 other URLs cat-footwear /shoes/running-x1 /category/footwear … + 83 URLs PURGE EVENT Purge API Call POST /purge tags: ["prod-9472"] → broadcasts to all PoPs tag lookup Evicted Entries /shoes/running-x1 ✗ /cart/9472 ✗ /search?id=9472 ✗ … 14 more evict all keys

Implementation Patterns

Pattern 1: Product catalog — purge by entity ID

Emit a tag for each logical entity the response depends on:

Cache-Control: public, s-maxage=86400, stale-while-revalidate=3600
Surrogate-Key: product-9472 category-footwear brand-acme

When the product record changes, purge product-9472. Every rendered page that referenced that product — PDPs, category pages, search results, recommendation widgets — evicts simultaneously.

Pattern 2: User-segment content with short TTL

For responses personalized at the CDN level (geo-targeted banners, A/B variants), combine short s-maxage with segment tags:

Cache-Control: public, s-maxage=300
Surrogate-Key: segment-us-east banner-summer2026
Vary: CF-IPCountry

Keep s-maxage short enough that stale delivery is acceptable between purge and propagation. Do not use private here — that prevents CDN storage entirely.

Pattern 3: API responses cacheable by resource ID

Cache-Control: public, s-maxage=60, stale-while-revalidate=30
Surrogate-Key: resource-orders resource-orders-user-4821
Content-Type: application/json

Two tags: one for the resource type (allows bulk invalidation), one for the specific user’s order set (allows targeted invalidation after order mutation). The stale-while-revalidate directive reduces perceived latency during revalidation; see combining directives safely for interaction rules.

Pattern 4: Static assets — immutable content, no tags needed

Cache-Control: public, max-age=31536000, immutable

Tags add overhead with no benefit for versioned static assets (JS bundles, hashed images). Use URL fingerprinting instead; invalidation happens naturally when the filename changes. Reserve tags for mutable, content-addressed resources.

Pattern 5: Authenticated endpoints — tags are irrelevant

Cache-Control: private, no-store

Responses scoped to a single user must not be stored in shared caches. Tags on private or no-store responses are never indexed. See public vs private cache scope for the full decision tree.


Server and CDN Configuration

Nginx — emit Surrogate-Key and strip on egress

# Upstream (origin) — emit tags based on $uri or application variable
location /products/ {
    proxy_pass http://app_upstream;

    # Application sets X-Internal-Tags; relay as Surrogate-Key to CDN
    proxy_pass_header X-Internal-Tags;

    # Rename internal header to Surrogate-Key before it reaches the CDN
    # (requires headers-more-nginx-module)
    more_set_headers "Surrogate-Key: $upstream_http_x_internal_tags";

    # Strip the tag header before it reaches end clients
    # (CDN should also strip, but belt-and-suspenders)
    more_clear_headers "Surrogate-Key";
}

For Nginx acting as a reverse proxy in front of Varnish or a CDN, the simpler approach is to emit the header at the application layer and configure the CDN to strip it on response egress.

Apache — emit and strip


    # Forward application-set header to CDN upstream
    Header always set Surrogate-Key "%{X-Internal-Tags}e"

    # Remove from client-facing response (CDN egress rule preferred)
    Header always unset Surrogate-Key

Cloudflare — Cache-Tag (Enterprise)

Cloudflare uses Cache-Tag as the header name. Tags are available on Enterprise plans.

Emit from origin:

Cache-Control: public, s-maxage=86400
Cache-Tag: product-9472,category-footwear

Cloudflare uses comma-delimited tags (unlike Fastly’s space-delimiter). Strip the header via a Cloudflare Transform Rule to prevent leakage to browsers.

Purge via API:

curl -X POST "https://api.cloudflare.com/client/v4/zones/YOUR_ZONE_ID/purge_cache" \
  -H "Authorization: Bearer YOUR_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"tags": ["product-9472", "category-footwear"]}'

Fastly — Surrogate-Key

Fastly uses space-delimited Surrogate-Key. Tags are available on all plans with VCL.

Emit from origin:

Cache-Control: public, s-maxage=86400
Surrogate-Key: product-9472 category-footwear

Purge via API:

curl -X POST "https://api.fastly.com/service/YOUR_SERVICE_ID/purge/product-9472" \
  -H "Fastly-Key: YOUR_API_TOKEN"

To purge multiple tags in one call (soft purge recommended for graceful degradation):

curl -X POST "https://api.fastly.com/service/YOUR_SERVICE_ID/purge" \
  -H "Fastly-Key: YOUR_API_TOKEN" \
  -H "Fastly-Soft-Purge: 1" \
  -H "Surrogate-Key: product-9472 category-footwear"

Strip in VCL:

sub vcl_deliver {
  unset resp.http.Surrogate-Key;
}

s-maxage and tag purgess-maxage sets the TTL that tag purges can override. Without s-maxage (or max-age) on a public response, the CDN may use heuristic caching and may not build the tag index at all on some platforms. Always set an explicit TTL alongside tags.

stale-while-revalidate and tag purges — a tag purge supersedes any stale-serving window. Once purged, the entry is gone from the cache; stale-while-revalidate has nothing to serve. The next request triggers a synchronous origin fetch. If you need graceful degradation during purge, Fastly’s soft purge marks the entry as stale rather than deleting it — background revalidation then runs, and stale content is served during that window.

no-cache and tag purgesno-cache requires revalidation on every request but permits storage. A CDN may still store the response and build tag indexes. However, every request triggers a revalidation pass to origin anyway, making tag purges less critical for no-cache responses.

Vary and tag scope — when a URL has multiple Vary-keyed variants, each variant is a separate cache entry. A tag purge evicts all variants that share that tag. This is usually the correct behavior: if the underlying resource changes, all encoding or language variants of it should be invalidated. See how cache keys are generated for how variants are keyed.

ETag / Last-Modified and tags — conditional validation is a freshness mechanism. Tag purges are an eviction mechanism. After a tag purge forces a cache miss, the next origin fetch returns a fresh response with a new ETag. Subsequent requests may then benefit from conditional validation (304 Not Modified) once the new entry ages past its TTL.


Verification Workflow

Step 1 — Confirm tag header emission from origin

curl -sI -H "Host: yourdomain.com" https://origin.internal/products/9472 \
  | grep -iE 'surrogate-key|cache-tag|cache-control|s-maxage'

Expected output includes both Cache-Control: public, s-maxage=... and the tag header. If the tag header is absent, the CDN will never build the index entry — purges will silently no-op.

Step 2 — Confirm CDN is storing the object (warm the cache)

# First request — expect MISS
curl -sI https://yourdomain.com/products/9472 \
  | grep -iE 'x-cache|cf-cache-status|age|surrogate-key|cache-tag'

# Second request — expect HIT, Age > 0
curl -sI https://yourdomain.com/products/9472 \
  | grep -iE 'x-cache|cf-cache-status|age'

The tag header should NOT appear in the client-facing response (it should be stripped at the CDN egress layer).

Step 3 — Fire the tag purge

# Cloudflare example
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": ["product-9472"]}' | jq '.success'

A true response confirms the purge was accepted. Note the timestamp.

Step 4 — Verify immediate MISS and cache repopulation

# Immediately after purge — expect MISS and Age: 0
curl -sI https://yourdomain.com/products/9472 \
  | grep -iE 'x-cache|cf-cache-status|age'

# Allow a few seconds for repopulation, then check HIT
sleep 3
curl -sI https://yourdomain.com/products/9472 \
  | grep -iE 'x-cache|cf-cache-status|age'

Age: 0 on the first post-purge request confirms eviction. A subsequent HIT with an incrementing Age confirms the cache repopulated from a fresh origin fetch.

Step 5 — Verify multi-URL purge coverage

# Check a second URL that shares the same tag
curl -sI https://yourdomain.com/category/footwear \
  | grep -iE 'x-cache|cf-cache-status|age'

If the category page was tagged with product-9472, it should also show a MISS immediately after the product purge. If it shows a HIT with an old Age, the tag was not applied to that URL at origin.


Failure Modes and Gotchas

  1. Tags absent from origin response — the most common failure. If the origin emits the tag header only on some code paths (e.g., cache hits from an app-level cache skip the tag emission middleware), the CDN index for those entries is never built. Purge calls succeed (API returns 200) but have no effect. Validate every origin code path emits tags, not just the slow path.

  2. Tag header stripped before reaching the CDN — a load balancer, WAF rule, or intermediate proxy may strip Surrogate-Key or Cache-Tag before the CDN sees them. Confirm tag headers arrive at the CDN layer using a CDN-specific debug header (e.g., Fastly-Debug: 1) or via CDN request logs.

  3. Tag index not propagated through shield — when origin shielding is active, edge PoPs may receive responses from the shield rather than origin. Verify your CDN propagates tag indexes from shield to edge PoPs, and that purge API calls invalidate both tiers. Fastly and Cloudflare Enterprise both propagate through shield; verify for other providers.

  4. Browser cache ignores CDN purges — a user who loaded the page 10 minutes ago may still have it in local disk cache with 50 minutes of max-age remaining. Tag purges have zero effect on browser storage. Mitigate with short max-age for mutable content (60–300 seconds) and rely on s-maxage for long CDN TTLs.

  5. Purge propagation latency across PoPs — purge signals propagate from the CDN control plane to all edge PoPs. During this window (typically milliseconds to a few seconds, longer for geographically distant PoPs), some PoPs may still serve the stale object. Do not assume instant global consistency. Log purge timestamps and correlate with edge log ingestion to measure convergence per region.

  6. Tag cardinality explosion — assigning a unique tag per user session or per request parameter creates millions of index entries. Most CDN platforms cap the number of tags per object (Cloudflare Enterprise: 1,000 tags per object; Fastly: 1,024). Exceed the limit and excess tags are silently dropped. Design tags around logical entities (product ID, category slug, page type) not request-specific values.

  7. Comma vs space delimiter mismatch — Cloudflare uses comma-separated tags; Fastly and Varnish use space-separated. Emitting Cache-Tag: product-9472 category-footwear to Cloudflare results in a single tag named product-9472 category-footwear (with a space), not two separate tags. Validate delimiter behavior per platform.

  8. no-store silently disables tagging — if a response path accidentally emits no-store alongside tag headers, the CDN stores nothing and the tag index is never built. This is common when authentication middleware adds no-store defensively. Audit your middleware stack for directives that conflict with intended cacheability.


FAQ

What is the difference between Surrogate-Key, Cache-Tag, and Surrogate-Control?

All three are vendor-specific extensions for CDN metadata. Surrogate-Key is used by Fastly, Varnish, and several other platforms. Cache-Tag is Cloudflare’s equivalent (Enterprise-only). Surrogate-Control is a Varnish/Fastly header that lets the CDN apply a different TTL from the Cache-Control value seen by browsers — it is distinct from the tag mechanism but often emitted alongside it. RFC 9111 standardizes none of them.

Can I use tag-based purges on non-Enterprise CDN plans?

Fastly includes surrogate key purging on all plans. Cloudflare requires an Enterprise plan for Cache-Tag purging; the Cache Reserve add-on does not unlock it on lower tiers. Varnish supports it natively with VCL. AWS CloudFront uses a similar mechanism via invalidation paths but does not support arbitrary tags — use CloudFront Cache Policies with custom cache keys instead.

Will a tag purge also invalidate my Nginx or Varnish origin-side cache?

Only if your purge implementation explicitly sends a purge signal to the origin-side cache. CDN purge APIs talk to CDN edge nodes, not to your origin infrastructure. If you run Varnish in front of your application server, you need a separate Varnish PURGE request to that layer, or use Varnish’s xkey (Surrogate-Key) module which supports server-side tag purges directly via curl -X XKEY-PURGE.

How do I purge all cached objects at once without iterating URLs?

Tag a broad category at the page-type level — for example, all product pages get Surrogate-Key: page-type-product. A single purge call targeting page-type-product evicts the entire product page population. Alternatively, most CDNs support a full-cache purge endpoint, but this is a high-blast-radius operation that temporarily collapses hit ratios and spikes origin traffic. Use broad tags instead.

What happens if a purge fires before the CDN has cached the object?

Nothing. The purge API call looks up the tag in the index, finds no matching keys, and returns success with zero evictions. The next request for that URL simply fetches from origin as a normal miss. This is safe behavior — purging an uncached object is a no-op, not an error.



Back to CDN Architecture & Edge Routing Strategies