CDN Cache Tags in 2026: Surrogate Keys, Purge-by-Tag, and Instant Invalidation

How CDN cache tags and surrogate keys work in 2026: headers, purge APIs, and stale-while-revalidate pairing on Fastly, Cloudflare, Vercel, Netlify, and Akamai.

Updated: August 18, 2026

CDN cache tags (also called surrogate keys) let you attach one or more string labels to a cached response so you can purge every object sharing that label with a single API call, instead of guessing URL patterns or waiting out a TTL. In 2026, Fastly, Cloudflare Enterprise, Vercel, Netlify, and Akamai all expose tag-based purge, but each one uses a different response header and a different purge endpoint. I've shipped cache-tag invalidation on all five, and honestly, the difference between a well-tagged CDN and a URL-purge CDN is the difference between a 40 ms TTFB and a 400 ms one after every content edit.

  • Cache tags are metadata labels attached to a cached response via a header. Names vary by vendor: Surrogate-Key (Fastly, Netlify), Cache-Tag (Cloudflare, Akamai), or revalidateTag() arguments (Vercel Next.js).
  • Tag-based purge invalidates every object carrying a tag in one call, with no URL enumeration, no wildcard globs, no full-zone wipes.
  • Fastly and Cloudflare complete a tag purge globally in under 150 ms and 300 ms respectively; Vercel and Netlify propagate in under a second for the tagged Data Cache.
  • Tag purge composes with stale-while-revalidate so users keep seeing a stale copy for milliseconds while the origin re-hydrates.
  • Verify purge with the Cache-Status header. A fwd=stale or fwd=miss on the next request confirms the tag was actually invalidated.
  • Cap total tags per object at 32 (Cloudflare's hard limit) and keep tag names under 16 chars. This is where most teams silently break their purge pipeline.

What is a CDN cache tag?

A cache tag is a short string label the origin attaches to a cached HTTP response through a response header. The CDN records that tag in a reverse index (tag → list of cached object IDs). When you later fire a purge-by-tag API call, the CDN walks the index, marks every matching object stale, and evicts them from disk on the next request. The URL of the object never enters the transaction. You can purge /blog/hello, /blog/hello.rss, /api/v2/posts/42, and /sitemap.xml in one call if they all share post-42.

The mental model matters. A cache key is what the CDN uses to look up a response for a request (usually URL + Vary headers). A cache tag is what the CDN uses to invalidate that response later. Keys are for read-time; tags are for write-time. Confusing the two leads to tagging schemes that look tidy on a whiteboard and blow up under real traffic when the same object is tagged with a customer ID, a locale, an experiment bucket, and a build hash all at once.

Tag purge is essential for full-stack teams because it decouples edge delivery latency from CMS write throughput. Editors can publish freely without a 60-minute TTL on the origin, and TTFB stays at CDN-cached speeds instead of falling back to origin on every content change.

How surrogate keys work under the hood

The surrogate-key concept originated in Varnish, which shipped it as xkey (the ban-lurker's replacement) and later as the commercial ykey module. Fastly's edge is built on a heavily modified Varnish, so the header name (Surrogate-Key) comes straight from that lineage. Other CDNs adopted the pattern with their own header name but the same data structure: a hash map keyed by tag, valued as a set of cache object handles.

The write path is almost free. When the origin sends the response, the CDN parses the tag header, splits on whitespace or commas depending on vendor, and appends each tag to the object's metadata. Fastly stores tags in the object's Varnish memory footprint; Cloudflare stores them in a colo-local index that gets synced to their control plane. The additional memory is roughly 16 bytes per tag per object.

The purge path is where CDNs diverge in interesting ways. Fastly does soft purge by default: the object is marked stale but kept on disk, so a request in the next few seconds can still serve it while the CDN pulls a fresh copy in the background. Cloudflare's tag purge is hard purge (the object is deleted immediately). This distinction matters enormously when you pair tag purge with stale-while-revalidate.

Fastly: the Surrogate-Key header

Fastly's implementation is the reference. Attach one or more space-separated tags to any origin response:

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=31536000, stale-while-revalidate=60
Surrogate-Control: max-age=86400
Surrogate-Key: post-42 author-mateo category-perf sitemap

The header is stripped before the response leaves the edge, so downstream browsers never see it. Purge a single tag with a POST to the Fastly API:

curl -X POST \
  -H "Fastly-Key: $FASTLY_API_TOKEN" \
  -H "Accept: application/json" \
  "https://api.fastly.com/service/$SERVICE_ID/purge/post-42"

For batch purge (say, invalidating a category and every post in it after a taxonomy rename) POST an array to /service/$SERVICE_ID/purge with a JSON body:

curl -X POST \
  -H "Fastly-Key: $FASTLY_API_TOKEN" \
  -H "Surrogate-Key: post-42 post-43 category-perf" \
  "https://api.fastly.com/service/$SERVICE_ID/purge"

Fastly guarantees purge propagation across every POP in under 150 ms in the p50 case and under 500 ms at p99. Soft purge is the default. Add -H "Fastly-Soft-Purge: 1" if you want to be explicit, or use it on an account where hard purge is the default. The upside of soft purge is that a user hitting the edge microseconds after your purge request still gets a response (marked stale) instead of a cold-origin round trip.

Cloudflare: the Cache-Tag header (Enterprise only)

Cloudflare uses Cache-Tag as a comma-separated header, and (this catches teams out) it is only respected on Enterprise plans. Free, Pro, and Business plans ignore the header entirely, silently, with no dashboard warning.

HTTP/1.1 200 OK
Cache-Control: public, max-age=86400
Cache-Tag: post-42, author-mateo, category-perf

Purge with the zone API:

curl -X POST \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"tags":["post-42","author-mateo"]}' \
  "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache"

Cloudflare's hard limits in 2026: 32 tags per response, 1000 characters total in the header, and 30 tags per single purge API call (batched with 200 requests/second). Exceed any of these and Cloudflare silently strips the header. The response is cached but has zero tags attached, so your purge call returns 200 OK and does nothing. Log the response header at the edge with a Worker if you're debugging.

Vercel: revalidateTag() with the Data Cache

Vercel's tag model in the Next.js 15+ App Router is programmatic instead of header-based. You tag data at fetch time and invalidate through a Server Action:

// app/blog/[slug]/page.tsx
export default async function Post({ params }) {
  const post = await fetch(
    `https://cms.internal/posts/${params.slug}`,
    { next: { tags: [`post-${params.slug}`, 'all-posts'] } }
  ).then(r => r.json())

  return <article>{post.body}</article>
}
// app/api/webhooks/cms/route.ts
import { revalidateTag } from 'next/cache'

export async function POST(req: Request) {
  const { slug } = await req.json()
  revalidateTag(`post-${slug}`)   // purges Data Cache
  revalidateTag('all-posts')      // purges index/listing pages
  return Response.json({ purged: true })
}

Under the hood, Vercel's Data Cache is a shared edge KV that stores the fetch response body plus an inverted tag index. revalidateTag() writes a purge event to that index, and edge functions serving the next request see the tag was invalidated and re-fetch from origin. Propagation is under a second globally in 2026, and the official revalidateTag docs confirm the guarantee.

Here's the nuance most teams miss: revalidateTag() only invalidates the Data Cache (fetch responses), not the Full Route Cache (rendered HTML). To also drop the pre-rendered HTML for the affected route, either use revalidatePath() or set the route to dynamic = 'force-dynamic'. Tag purge without a matching route revalidation gives you fresh data on the server but stale HTML at the edge. It's a footgun I've debugged for three separate teams.

Netlify: Cache-Tags on Edge Functions

Netlify's Edge Functions and On-Demand Builders both accept a Cache-Tags header (note the plural: Netlify uses Cache-Tags, Cloudflare uses Cache-Tag) as part of their fine-grained caching layer:

// netlify/edge-functions/post.ts
export default async (req: Request) => {
  const post = await getPost(req.url)
  return new Response(renderHtml(post), {
    headers: {
      'Content-Type': 'text/html',
      'Netlify-CDN-Cache-Control': 'public, s-maxage=31536000',
      'Cache-Tags': `post-${post.id}, author-${post.authorId}`,
    },
  })
}

Purge via the Netlify API using the personal access token scope sites:write:

curl -X POST \
  -H "Authorization: Bearer $NETLIFY_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{"cache_tags":["post-42"]}' \
  "https://api.netlify.com/api/v1/purge"

Netlify uses Netlify-CDN-Cache-Control for edge-only directives, so you can send a shorter Cache-Control to browsers and a long s-maxage to the CDN. This split is essential when you want browsers to always revalidate but the edge to cache aggressively. It's the exact pattern most CMS-fronted sites want.

Akamai: Edge-Cache-Tag

Akamai's Fast Purge uses Edge-Cache-Tag as the response header. Comma-separated, up to 128 tags per object, 128 characters per tag:

HTTP/1.1 200 OK
Cache-Control: public, max-age=86400
Edge-Cache-Tag: post-42, author-mateo, sitemap

Purge through the Fast Purge v3 API. Akamai's tag purge propagates in about five seconds globally, which is the slowest of the five vendors here, but still fast enough for editorial workflows:

curl -X POST \
  -H "Authorization: $EDGEGRID_AUTH_HEADER" \
  -H "Content-Type: application/json" \
  --data '{"objects":["post-42","author-mateo"]}' \
  "https://api.ccu.akamai.com/ccu/v3/invalidate/tag/production"

Akamai's tag purge is transactional across their network: either every edge node acknowledges or the purge fails. That's stronger consistency than Cloudflare offers, at the cost of the extra propagation seconds.

A tagging strategy that survives production

The biggest failure mode I see is tag sprawl. A team starts with post-42, adds author-mateo, then lang-en, then experiment-b-purple-cta, then build-abc123, and by month three every object has 40 tags, the purge API is rate-limited, and nobody remembers what tags to purge on which event.

The rule I use: one tag per invalidation event you can name. If you cannot describe the event in five words ("post edited", "author profile updated", "sitemap regenerated"), you don't need a tag for it. Every tag should map to exactly one webhook or cron job that fires the purge. Here's the taxonomy I run on production sites:

  • Entity tags: post-42, user-99. One per row in the primary content table. Purged on any write to that row.
  • Collection tags: posts-all, category-perf, homepage. Purged when any member of the collection changes. Cap at ~50 total collection tags across the site.
  • Layout tags: nav, footer. Purged when the layout template changes. Attached to every page.
  • Build tags: build-$SHA. Attached to every response. Purged only on emergency full-site invalidation. Never purge this in normal operations.

Do not tag on user identity, session, or query parameters. That's what cache keys are for. If your response actually varies by user, use Vary or a bypass rule; tags are the wrong tool.

Combining tag purge with stale-while-revalidate

Tag purge and stale-while-revalidate are complements, not alternatives. SWR gives you fast responses during origin re-fetch; tag purge gives you the ability to trigger that re-fetch precisely. Together they yield a pattern I call zero-perceived-latency invalidation:

Cache-Control: public, max-age=0, s-maxage=31536000, stale-while-revalidate=60
Surrogate-Key: post-42 author-mateo

The max-age=0 tells browsers to always revalidate. The s-maxage=31536000 tells the shared CDN cache to hold the response for a year, trusting that you'll purge it via tag when it changes. The stale-while-revalidate=60 gives the CDN a 60-second window after purge to serve the stale copy while re-fetching. Net result: a content edit fires a webhook, the tag purge propagates in 150 ms, and the next request gets a stale response instantly while the origin fetch happens in the background. The user perceives zero latency change.

This pattern requires soft purge (Fastly, Vercel) or a CDN that respects SWR after purge. Cloudflare does with the correct plan settings; Netlify's edge respects it via Netlify-CDN-Cache-Control. Verify by watching request timings before and after a scheduled purge. You should see no origin round-trip in the request path for the first 60 seconds after purge.

Verifying purges with the Cache-Status header

Never trust that a purge worked based on a 200 OK from the CDN API. Verify by looking at the next request's Cache-Status response header, defined in RFC 9211:

# Before purge, served from cache
Cache-Status: Fastly; hit; ttl=86400; key=post-42

# Immediately after purge with soft purge + SWR
Cache-Status: Fastly; hit; ttl=0; fwd=stale

# After SWR window expires (or hard purge)
Cache-Status: Fastly; fwd=miss; stored

Add a synthetic monitor that fires curl -I against the tagged URL before and after a test purge and asserts the Cache-Status transitions. That gives you a real integration test for your purge pipeline instead of the "we fired the webhook, hope it worked" pattern that fails silently for weeks.

Common pitfalls that break tag purge

I've written this list from real incidents. Every one of these has cost someone an outage:

  1. Header case sensitivity. Cloudflare's Cache-Tag versus Netlify's Cache-Tags versus Akamai's Edge-Cache-Tag. Copy-pasting the wrong plural is the most common bug. Templatize per-vendor.
  2. Silent tag-limit truncation. All five vendors silently drop tags once you exceed their limit. Log the emitted header and set a CI check that fails builds emitting more than the vendor's cap minus 2.
  3. Tags on error responses. Tagging a 404 or 500 means a later purge invalidates the cached error, forcing another origin hit that also 500s. Only tag 2xx and 3xx.
  4. Origin-only tagging. If you set tags at the origin but a reverse proxy (nginx, Cloudflare Workers) rewrites the response, tags get stripped before the CDN sees them. Test with the exact chain that runs in production.
  5. Ignoring the browser cache. Tag purge only affects the shared CDN cache. If Cache-Control: max-age=3600 hits browsers, users see stale content for up to an hour after purge. Use max-age=0, s-maxage=<long> to keep browsers revalidating.
  6. Purging on read. I've seen a webhook handler purge on every read for "safety". That turns a 200 ms edit into a 50k-request/second purge storm and rate-limits the whole account. Purge only on write.

Frequently Asked Questions

What is the difference between a cache tag and a cache key?

A cache key is what the CDN uses to look up a response for an incoming request, usually the URL plus any Vary headers. A cache tag is metadata attached to a stored response so you can invalidate it later without knowing its URL. Keys are for reads; tags are for writes.

How do I purge cache by tag on Cloudflare?

Send a POST to /zones/$ZONE_ID/purge_cache with a JSON body {"tags":["your-tag"]}. This requires a Cloudflare Enterprise plan; free, Pro, and Business plans ignore the Cache-Tag response header, so no tags are recorded and no purge happens.

Can you tag cache in Vercel Next.js?

Yes. Pass { next: { tags: ['your-tag'] } } as the second argument to fetch(), then call revalidateTag('your-tag') from a Server Action or route handler. This purges the Data Cache; use revalidatePath() alongside it if you also need to invalidate rendered HTML in the Full Route Cache.

How fast does a tag purge propagate globally?

In 2026: Fastly under 150 ms p50, Cloudflare under 300 ms, Vercel Data Cache under one second, Netlify around one to two seconds, and Akamai around five seconds. All are fast enough for interactive editorial workflows.

Do cache tags work with stale-while-revalidate?

Yes, and they should be used together. Combine tag purge with Cache-Control: max-age=0, s-maxage=<long>, stale-while-revalidate=60 so purged content is served stale for up to 60 seconds while the origin re-fetch happens in the background. The user sees no latency spike.

How many tags can I attach to one response?

Cloudflare caps at 32 tags and 1000 total characters. Fastly allows up to 256 tags but headers get expensive to parse past 100. Netlify allows 100. Akamai allows 128. In practice, keep any single response under 20 tags. Anything more is a sign of tag sprawl.

Mateo Silva
About the Author Mateo Silva

Full-stack performance lead bridging frontend perf with backend latency. Cache invalidation is his love language.