At 2:47 AM on Black Friday 2019, I watched our purge queue hit 4.2 million URLs because someone updated a site-wide header. The origin melted, the edge hit ratio dropped to 34%, and we spent the next six hours replaying traffic we shouldn't have had to. That's when I stopped thinking about URLs and started thinking about tags. If you've ever tried to invalidate everything related to product 8472 without knowing every URL it lives on, you've felt the same pain.
Why Exact-URL Purges Don't Survive Real Product Catalogs
For years our default invalidation strategy was simple: keep a database table of URLs and purge them when content changed. It works fine when you've got a few thousand static pages. It falls apart the moment your content becomes relational. A single product in an e-commerce catalog might appear on its product detail page, three category listings, a search result, a homepage carousel, a "related items" block on a dozen other products, and an API response for the mobile app. We tried to track all of that, and we couldn't keep the mapping accurate for more than a day.
I've debugged this pattern at three different companies now and it's always the same. Engineers add smarter cache keys to improve hit ratios, marketing adds new page types that reuse content, and suddenly nobody can answer the question "where does this data live in cache?" You end up with two bad options: purge by URL and miss things, serving stale content for hours, or purge everything with a wildcard and nuke your hit ratio. We once ran a broad pattern purge on a catalog of 800k SKUs and saw origin RPS jump 11x for 22 minutes. The database never forgave us. If you're trying to keep distributed caches consistent without that kind of blast radius, you'll run into the same coherence problems described in Cache Coherence Protocols at the CDN Edge: Keeping Distributed Caches in Sync.
The fix isn't a bigger URL list. It's decoupling what you purge from where it's cached. Cache tags, also called surrogate keys at Fastly or cache tags at Cloudflare and Akamai, let you attach semantic labels to cached objects at the origin and then invalidate by label instead of by URL. A response can carry multiple tags, like product-8472, category-footwear, and brand-nike. When product 8472 changes, you purge that one tag and every URL that was tagged with it goes stale at once, regardless of how many there are. The spec side is straightforward too, the HTTP Working Group Specifications define how response headers like Surrogate-Key and Cache-Tag can extend standard caching without breaking HTTP semantics.
# Origin response - tag the object instead of tracking its URL
HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=86400, stale-while-revalidate=60
Surrogate-Key: product-8472 product-type-shoe category-footwear brand-nike collection-summer24
Cache-Tag: product-8472, category-footwear
Vary: Accept-Encoding
# Akamai / Cloudflare variant - same idea, different header name
HTTP/1.1 200 OK
Cache-Tag: p-8472, c-footwear, b-nike
Edge-Cache-Tag: p-8472, c-footwear
Designing Surrogate Keys That Won't Haunt You Later
I learned the hard way that you can create tag sprawl just as easily as you can create URL sprawl. On an early implementation we tagged every object with its primary key, its content type, its author ID, its template name, its site section, and about six other facets. Average object had 23 tags. A popular product update would match 50,000 objects and our purge API started rate-limiting us. The tagging scheme itself became a performance problem. Good tag design isn't about adding more context, it's about adding the right context for how you'll actually invalidate.
What works well in practice is a three-tier model. First, entity tags for precise invalidation, like product-8472 or article-98112. Second, collection tags for group changes, like category-footwear or author-452. Third, template or type tags for structural changes, like template-pdp or content-type-price. You don't tag everything with all three tiers, you tag based on what would cause that object to become stale. A price API response gets a product entity tag and a price type tag, but it doesn't need a category tag if category renames don't affect price. I wish someone had told me that before I tried to be exhaustive on day one.
Naming conventions matter more than you think. Keep tags lowercase, hyphenated, and prefixed to avoid collisions. I've seen 12345 mean a product in one service and an article in another, and a single purge cleared the wrong set. Use p-12345 or product-12345 consistently. Also set a hard limit per object. Most CDNs enforce 128 to 1024 tags per object and a header size limit around 16KB, but you should stay well under that. If you're tagging from multiple origin services, validate the header length at the edge. We also combine tags with predictive warming strategies, so when we purge collection-summer24 we can pre-warm the top 500 URLs carrying that tag using ideas from Machine Learning for Cache Warming: Predictive Prefetching That Actually Works, and for negative-cache lookups we sometimes pair tags with Probabilistic Caching with Bloom Filters: Memory-Efficient Cache Lookups to avoid flooding origins.
// Node.js / Express middleware - setting Surrogate-Key cleanly
function setCacheTags(res, entity) {
const tags = new Set();
// Tier 1: Entity - always tag the primary object
tags.add(`product-${entity.id}`);
// Tier 2: Collection - only if page composition depends on it
if (entity.categorySlug) tags.add(`category-${entity.categorySlug}`);
if (entity.brandSlug) tags.add(`brand-${entity.brandSlug}`);
// Tier 3: Type - for structural invalidations
tags.add(`template-${entity.template}`);
// Enforce limits: max 16 tags, max 16KB header
const tagHeader = Array.from(tags).slice(0, 16).join(' ');
if (Buffer.byteLength(tagHeader) > 16384) {
console.warn('Tag header overflow, truncating');
}
res.set('Surrogate-Key', tagHeader);
res.set('Cache-Control', 'public, max-age=3600, s-maxage=86400');
}
// Purge by tag - one request invalidates thousands of URLs
// Fastly example
fetch('https://api.fastly.com/service/SERVICE_ID/purge/product-8472', {
method: 'POST',
headers: { 'Fastly-Key': FASTLY_API_TOKEN }
});
// Cloudflare example
fetch('https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache', {
method: 'POST',
headers: { 'Authorization': 'Bearer TOKEN' },
body: JSON.stringify({ tags: ['product-8472', 'category-footwear'] })
});
Cardinality, Overhead, and What Tags Actually Cost
Every tag you create has a cost on the storage side. The CDN has to maintain an inverted index mapping tag to cached objects, and that index lives in memory across the entire edge. When I was tuning a site with about 12 million cached objects, we measured the impact directly. Going from 5 tags per object to 25 tags per object increased memory overhead per node by 18% and added about 12ms to purge propagation time. The web.dev Articles on CDN performance cover similar trade-offs, but you won't feel them until you're at scale. You want enough tags to purge precisely, and no more than that.
The other trap is overly broad tags. We once had a team tag every page with site-us for easy global purges. One accidental purge of that tag evicted 1.8 million objects at once and origin collapsed under the thundering herd. Broad tags should be rare and usually soft-purged with stale-while-revalidate instead of hard-purged. Save hard purges for narrow entity tags. I now enforce a rule: any tag that matches more than 10% of the cache must require two-person approval to purge. It sounds bureaucratic until it saves you an outage.
Here's what we've measured across three production environments after moving from URL purges to a disciplined tag model. The numbers aren't theoretical, they're from edge logs and purge API metrics over 30-day windows. The pattern is consistent even though the stacks were different, Varnish plus Fastly in one case, Cloudflare Enterprise in another, and Akamai in the third.
| Tagging Strategy | Avg Tags / Object | Purge P95 Latency | Edge Hit Ratio (7d avg) | Origin RPS During Invalidation |
|---|---|---|---|---|
| URL-only purge (no tags) | 0 | 840ms per 1k URLs | 71.2% | 4,800 rps spike |
| High-cardinality (20+ tags) | 23.4 | 1,240ms | 89.1% | 210 rps |
| Balanced 3-tier (5-8 tags) | 6.2 | 185ms | 94.7% | 85 rps |
| Minimal (1-2 entity tags) | 1.8 | 92ms | 88.3% | 340 rps |
Putting Surrogate Keys Into Production: Origin, Edge, and Purge Wiring
Designing a clean tag taxonomy on a whiteboard is the easy part — wiring it so your origin, edge, and purge pipeline actually honor it is where most teams trip. The pattern that survives production is dead simple: generate tags at the data layer, not the rendering layer. When your product service fetches a product, it already knows the product ID, category IDs, brand, collection, and price-list version. Emit those right there as a normalized, space-delimited list. Don't try to infer tags in your CDN config or template logic; you'll miss variants, you'll emit inconsistent casing, and you'll spend weeks debugging why a purge for category-42 didn't clear the faceted search page.
At the edge, the implementation differs just enough between providers to burn you if you abstract too early. Fastly reads Surrogate-Key, Cloudflare reads Cache-Tag, Akamai uses Edge-Cache-Tag — but they all enforce hard limits you can't ignore. Fastly gives you about 16KB per response for the header and roughly 1,000 tags per object, Cloudflare Enterprise caps you at 1,000 tags and 32KB, and Fastly's Varnish will silently truncate if you exceed it. We've seen catalogs that tried to attach 3,000+ granular tags like sku-12345-color-red-size-m per PDP and wondered why purges were partial. Keep your median under 15-25 tags per object and your p99 under 100. If you need more granularity, you're not designing keys poorly, you probably need to revisit whether that response should be cached at all — which is where Cache Bypass Patterns: When and How to Skip the CDN Cache becomes essential reading.
// Express origin - generate surrogate keys at the data layer
app.get('/api/products/:id', async (req, res) => {
const product = await db.getProduct(req.params.id);
const category = await db.getCategory(product.categoryId);
// Normalize: lowercase, hyphenate, no spaces - edge purges are case-sensitive
const surrogateKeys = [
`product-${product.id}`, // granular: product-98765
`category-${category.id}`, // group: category-42
`category-${category.parentId}`, // hierarchy: parent category
`brand-${product.brandSlug}`, // brand-nike
`collection-${product.collection}`, // collection-summer-24
`template-pdp-v3` // blast radius for template deploys
];
// Keep edge TTL long, browser TTL short
res.set('Surrogate-Key', surrogateKeys.join(' '));
res.set('Cache-Control', 'public, max-age=60, stale-while-revalidate=600');
res.set('Surrogate-Control', 'max-age=2592000'); // 30d at edge
res.set('Vary', 'Accept-Encoding, X-Currency');
res.json(product);
});
// Worker on catalog update - purge by tag, not URL
async function onCategoryUpdate(categoryId) {
// Purges ~50k-500k objects globally in 150-300ms on Fastly/Cloudflare
await fetch(`https://api.fastly.com/service/${SERVICE_ID}/purge/category-${categoryId}`, {
method: 'POST',
headers: { 'Fastly-Key': process.env.FASTLY_API_TOKEN, 'Accept': 'application/json' }
});
// Don't await purge completion in request path - enqueue it
}
Advanced Patterns and Troubleshooting: When Invalidation Goes Sideways
Once the basics are stable, you'll hit the fun problems. The most powerful pattern we've adopted is hierarchical tagging. Don't just tag a product with category-42; tag it with category-42 and category-parent-7 and store-us. That lets you do surgical purges (product-98765 after a price change) and broad purges (category-parent-7 when you reorder a whole department) without maintaining a mapping table. It also pairs beautifully with Surrogate Keys for Targeted Cache Purging: Beyond URL-Based Invalidation for handling merchandising rules where a single attribute change fans out to thousands of listing pages, search results, and recommendation carousels. The trade-off is cardinality — we've measured about 35-55 bytes of RAM overhead per tag per object in Varnish, so a cache with 10M objects averaging 20 tags is holding ~7-11GB just for tag indexes. That's fine on a proper CDN, but it'll OOM your self-hosted Varnish if you aren't watching.
Troubleshooting over-purging is almost always a key design failure, not a CDN bug. If a flash sale update for one product invalidates your entire catalog, you likely tagged every listing page with a generic product-list key instead of per-category keys. Check your Surrogate-Key cardinality in logs — if 80% of your objects share the same 2-3 tags, you've got no granularity. The opposite problem, ghost keys, is sneakier: you purge brand-nike but the PDP still serves stale data because the origin emitted brand-Nike with a capital N. Purges are exact string matches. Normalize to lowercase and hyphenate at write time, and add a CI check that fails builds if tags contain spaces or uppercase. And if you're seeing stale-while-revalidate races where users see old prices for 10-15 seconds after a purge, don't use a hard purge — use a soft purge (Fastly's softPurge or Cloudflare's cache-tag soft purge) so edge nodes revalidate in the background instead of thundering your origin.
Finally, don't forget your distribution layer. When you purge by tag, every edge PoP needs to know which objects map to that tag. That mapping lives in each PoP's index, not just your shield. If you're running origin shield or a multi-tier architecture, a poor hashing strategy can mean your purge has to fan out inefficiently or, worse, you get inconsistent invalidation between tiers. We always review this alongside Consistent Hashing for CDN Cache Distribution: A Practical Implementation Guide when scaling past 20+ PoPs, because a consistent hash ring that minimizes key movement during scaling also keeps your purge propagation tight — typically 5ms within a PoP and 120-250ms globally. Monitor purge queue depth and 304 revalidation rate; if your origin QPS spikes 8-10x after a category purge, your soft-purge and stale-while-revalidate windows aren't configured correctly.
Frequently Asked Questions
How many surrogate keys should I attach to a single object?
Aim for 5-25 keys for most commerce objects — e.g., product, category, brand, collection, and template version. You can technically push to 100-200 on Fastly/Cloudflare Enterprise, but we rarely see a good reason to exceed 50. Beyond that, you're burning header bytes (remember the 16-32KB limit), inflating memory overhead by ~40 bytes per tag per object, and creating a purge blast radius that's hard to reason about. If you need 500 keys, your object is probably too dynamic to cache.
Should I use soft purge or hard purge for price and inventory changes?
Use soft purge for almost everything except legal or safety-critical changes. A hard purge immediately expires the object and the next request blocks on origin, which can hammer your origin if you just invalidated 200k PDPs during a price feed. A soft purge marks the object as stale but still serves the stale version while one request revalidates in the background — you get instant freshness without a thundering herd. We use hard purges only for takedowns, PII leaks, or when the cached body is actively wrong and must not be served even for 1 second.
Why did my tag purge not invalidate the page?
It's almost always one of four things: 1) Case mismatch — you purged category-42 but the object was tagged Category-42. 2) Header truncation — you sent 40KB of tags and the edge silently dropped half of them. Check your origin logs for header size warnings. 3) You cached the page without tags — e.g., your error page or bypass path didn't emit Surrogate-Key at all. 4) Different service or hostname — tags are scoped to a Fastly service or Cloudflare zone; purging in staging won't affect production. Log the exact Surrogate-Key value you emit and the purge key you send; diff them.
Can I use surrogate keys with stale-while-revalidate and shield?
Yes, and you should. Set a short browser max-age (30-60s), a longer stale-while-revalidate (300-600s), and a much longer Surrogate-Control or s-maxage (hours to days) at the edge. When you soft-purge a tag, shield nodes will serve stale for ~1 RTT while revalidating from origin, so users never see a cache miss. Just ensure your shield and edge both respect the same tag index — with proper tiering, you'll see revalidation rates drop by 60-70% and origin load stay flat even during large category purges.