CDN Content Taxonomy: Organizing Assets for Efficient Edge Delivery and Invalidation

I’ll never forget the 2 a.m. call from a large e-commerce client back in 2019. Their entire product image set was stuck serving a three-day-old sale banner after a failed purge, and the reason wasn’t a bug in the CDN — it was their asset organization. Everything lived under a single /assets/ bucket with one global cache rule, so a single invalidation either did nothing or cleared the whole edge. That night is why I tell every team I work with that your content taxonomy isn’t just folder hygiene, it’s your cache control plane.

Why a Flat Asset Bucket Breaks Your CDN Structure

When you’re moving fast, a flat structure feels efficient. You toss images, JavaScript bundles, JSON feeds, and marketing PDFs into /static or /assets and set a single TTL. I’ve seen startups run like this for years until traffic spikes or a bad deploy forces a full-site purge. The problem is that the CDN can only be as granular as your content taxonomy allows. If the edge can’t distinguish a mutable product price API response from an immutable WebP hero image, it has to treat them with the same rules, and you’ll end up either over-caching dynamic data or under-caching static assets.

A good CDN structure mirrors how content actually behaves, not just how your repo looks. I like to start by sorting assets along two axes: mutability and blast radius. Mutability asks, how often does this file change without a name change? An app.a3f8c2.js bundle with a content hash is immutable; /api/products/123.json is highly mutable. Blast radius asks, who is affected if this is stale or purged? Purging a shared global.css touches every page, while purging /products/shoes/nike-air-max/ only touches one category. When you map those two dimensions first, your CDN Content Lifecycle Management: From Upload to Expiration and Archival becomes far more predictable because you’re not trying to force one lifecycle onto five different content types.

I learned this the hard way on a media site migration where we inherited 400,000 images all named sequentially like img_10293.jpg with no folder context. Surrogate keys were impossible and cache hit ratios hovered around 62%. Once we reorganized by content category and date — /images/editorial/2024/10/, /images/user-uploads/, /images/ads/ — we could apply distinct cache policies and tag them properly. Hit ratios jumped to 94% without adding more edge nodes.

# Example taxonomy-driven CDN configuration (Varnish / Fastly VCL style)
# Immutable hashed assets - cache for a year, no revalidation needed
if (req.url ~ "^/static/(js|css)/.*\.[a-f0-9]{8}\.(js|css)$") {
  set beresp.ttl = 365d;
  set beresp.http.Cache-Control = "public, max-age=31536000, immutable";
  set beresp.http.Surrogate-Key = "static-immutable";
}

# Mutable product data - short TTL, must revalidate
if (req.url ~ "^/api/products/") {
  set beresp.ttl = 60s;
  set beresp.http.Cache-Control = "public, max-age=60, stale-while-revalidate=30";
  set beresp.http.Surrogate-Key = "api product-" + regsub(req.url, "^/api/products/([^/]+)/.*", "\1");
}

# Editorial images - medium TTL, surrogate key by section
if (req.url ~ "^/images/editorial/") {
  set beresp.ttl = 7d;
  set beresp.http.Surrogate-Key = "images editorial-" + regsub(req.url, "^/images/editorial/([^/]+)/.*", "\1");
}

Building Content Categories That Make Purging Painless

Purging is where a sloppy taxonomy finally bills you. If you rely on URL-based purges with wildcards like /assets/*, you’re playing a dangerous game — most CDNs rate-limit purges and wildcard purges are often eventually consistent for a few seconds. I’ve watched a team try to purge 50,000 product variants one-by-one via API during a pricing error, hitting 429 errors while customers still saw the wrong price. You don’t want to be in that position.

Instead, I design content categories to be purge-friendly from day one using surrogate keys (also called cache tags or cache keys depending on your provider). Think of them as labels you attach at the origin that the CDN stores alongside the cached object, completely separate from the URL path. Your taxonomy then becomes a tagging strategy. For example, a single product page /p/sku-88473 might carry the keys product-88473, category-shoes, brand-nike, and template-pdp. When the shoe’s price changes, you purge product-88473 and you’re done in under 150ms globally — no need to guess URLs. This approach is core to modern Content Versioning Patterns for CDN: Atomic Deploys and Rollback-Ready Assets, where versioned deploys rely on tags rather than path purges.

Keep your tag cardinality in check, though. I try to limit any object to 5-8 keys max and keep the total tag namespace under 10,000 distinct values. One client tagged every image with its full S3 ETag and user ID, creating millions of unique keys that blew up their purge queue and header size. Stick to logical business categories that match how your editors and engineers actually think about invalidation: product, collection, campaign, template, locale. You’ll thank yourself when you need to roll back a campaign in a hurry.

HTTP/1.1 200 OK
Content-Type: text/html
Cache-Control: public, max-age=300
Surrogate-Key: product-88473 category-shoes brand-nike template-pdp locale-en-US
Surrogate-Control: max-age=86400
CDN-Cache-Control: max-age=86400, stale-while-revalidate=60

// Origin response headers for a product detail page
// Purge with: POST /purge { "surrogate_keys": ["product-88473"] }
// Or roll back campaign: POST /purge { "surrogate_keys": ["campaign-fall-sale"] }

Aligning Asset Organization With TTLs and Freshness

Once your categories are clean, you can finally set sane freshness rules. I see teams set TTLs based on gut feeling — “let’s cache everything for an hour” — and then wonder why their origin is hammered or their content is stale. Your taxonomy should drive your CDN Content Expiration Policies: Setting Freshness Rules That Match Content Dynamics directly. The HTTP Working Group Specifications at HTTP Working Group Specifications define how Cache-Control, CDN-Cache-Control, and Surrogate-Control interact, and the guides on web.dev Articles do a nice job explaining immutable and stale-while-revalidate for browsers versus edges. Use those layers intentionally.

What I do in practice is create a matrix where each content category gets its own origin TTL, edge TTL, and browser TTL. Immutable assets with a hash get a year at the edge and in the browser. User-generated avatars might get a day at the edge but need instant purges on change, so I keep browser TTL short and rely on tags. API responses that personalize pricing get 60 seconds with stale-while-revalidate so the edge can serve stale content while fetching fresh data — users never wait on origin. You’re not just organizing files; you’re telling the CDN how to behave for each type of work.

Here’s the template I’ve used with three different retail CDNs that consistently gets us above 90% hit ratio while keeping purges under a second. Your numbers will vary by traffic and geography, but the ratios hold up pretty well.

Content Category Example Path / Key Edge TTL Browser TTL Avg. Hit Ratio
Immutable Hashed Static /static/js/app.a3f8c2.js (Surrogate-Key: static-immutable) 365 days 31536000s immutable 98.7%
Editorial / Marketing Images /images/campaigns/fall-sale/hero.webp 7 days 86400s 92.4%
Product API (Mutable) /api/products/88473.json (Key: product-88473) 60s + 30s stale-while-revalidate 0s (private) / 60s public 78.1%
User-Generated Content /ugc/avatars/user-49201.jpg (Key: user-49201) 24 hours 3600s 89.3%
HTML Documents (SSR) /p/sku-88473 (Keys: template-pdp, product-88473) 300s + 60s stale-while-revalidate 0s, must-revalidate 84.5%

Putting Taxonomy Into Practice: Surrogate Keys, URL Design, and Origin Headers

Taxonomy doesn't live in a spreadsheet — it lives in headers and URL structure. If your origin isn't emitting the right signals, your CDN can't act on your categories. The most scalable pattern we've standardized on is surrogate keys (also called cache tags or cache keys depending on your provider). Instead of purging by URL — which falls apart when you've got 4.7 million product variants — you tag each asset with 2-5 logical keys at origin and purge by tag. On Fastly that's Surrogate-Key, on Cloudflare and Akamai it's Cache-Tag or Edge-Cache-Tag, but the mechanics are identical.

Your URL design has to reinforce this. Don't bury taxonomy in query strings like /asset?id=12345&type=hero — most CDNs either ignore query strings for caching by default or explode your cache key cardinality. Use a hierarchical path that mirrors your categories: /c/{category}/{subcategory}/{asset-id}-{variant}.webp. That gives you natural prefix purging and lets you set path-based rules. For example, /c/product/* gets a 24-hour edge TTL with stale-while-revalidate of 6 hours, while /c/marketing/campaign/* gets 300 seconds and stale-if-error of 86400. When you're federating content from multiple origins, this path structure becomes even more critical — see how CDN Content Federation: Aggregating Content from Multiple Origins into Unified Delivery handles routing without collapsing your taxonomy.

Implementation-wise, you'll configure your origin (or origin shield) to emit the tags, and your CDN to respect them. We've found a hard limit that saves headaches: keep it under 16 tags per object and keep tag length under 64 characters. More than that and you'll hit header bloat — we once saw a 12KB Surrogate-Key header push response headers over Cloudflare's 16KB limit and cause silent tag truncation. Here's a production pattern that works across Fastly, Akamai, and Cloudflare Enterprise:

# Origin response (Nginx / Node / S3 Lambda@Edge)
HTTP/1.1 200 OK
Content-Type: image/webp
Cache-Control: public, max-age=31536000, immutable
CDN-Cache-Control: public, max-age=86400, stale-while-revalidate=21600
Surrogate-Key: prod-image cat-footwear brand-nike product-98765
Cache-Tag: prod-image, cat-footwear, brand-nike, product-98765
Vary: Accept, Accept-Encoding

# VCL / Edge logic - don't cache HTML with same rules
# varnish / fastly vcl_recv snippet
if (req.url ~ "^/c/product/") {
  set req.http.Surrogate-Capability = "abc=ESI/1.0";
}
# Purge by tag - not by URL
# curl -X POST "https://api.fastly.com/service/xxx/purge/prod-image" 
# Purges ~250k objects in <150ms vs. 45+ seconds for URL wildcard purge

Advanced Patterns and Troubleshooting When Purging Doesn't Behave

Even with a clean taxonomy, you'll hit two failure modes: over-purging and under-purging. Over-purging usually means your tags are too broad. If tagging every product image with product-all and you purge that tag on a single SKU update, you're invalidating 1.2 million objects to refresh 12. That's a cache hit ratio cliff — we've watched edge hit ratio drop from 96.4% to 71% in 10 minutes from one sloppy tag. Fix it by making tags specific and composable: product-98765 plus cat-footwear, not one mega-tag. You can then run an atomic purge of product-98765 without touching the rest of the catalog.

Under-purging is sneakier and almost always a consistency problem. You purged at the edge in Virginia but shoppers in Singapore still see the stale hero banner for 4-6 minutes. That's not a taxonomy bug, that's replication lag. Different CDNs handle purge propagation with different guarantees, and you need to know which one you're paying for. If you're running multi-region origins or shield hierarchies, your purge has to achieve consistency across the entire mesh, not just one PoP. For the trade-offs between speed and correctness, dig into CDN Content Replication and Consistency Models: Eventual, Strong, and Causal — it'll explain why that "instant purge" SLA of 150ms actually means 150ms to *commit* and up to 5-15 seconds to converge globally.

When you're debugging, don't trust the dashboard. Validate with headers and edge tracing. Add Fastly-Debug: 1 or Pragma: akamai-x-cache-on and check X-Cache: MISS vs. HIT plus Age and Surrogate-Key on the second request. If Age resets but content is stale, your origin isn't updating the source — the CDN purged correctly. If Age doesn't reset after a tag purge, your tag wasn't attached — check for header stripping at your load balancer or WAF. We've seen AWS ALBs silently drop headers with underscores. Finally, if purges are slow to reach distant edges, you may need to rethink your synchronization topology entirely; Multi-Region Content Synchronization: Keeping CDN Edges in Agreement covers patterns for active-active origins where we keep purge propagation under 2 seconds p95.

Frequently Asked Questions

How many surrogate keys or cache tags should I use per asset?

Keep it to 3-6 highly selective tags. One for asset type (e.g., prod-image), one for category, one for specific entity ID (product-12345), and optionally one for campaign or version. More than 10-16 tags per object creates header bloat, hits provider limits (Fastly limits the Surrogate-Key header to 16KB), and makes debugging painful. You'll get better precision with fewer, better-chosen tags than with 20 broad ones.

Should I use URL-based purging or tag-based purging?

Use tag-based purging for anything categorized. URL purges are fine for a one-off file like /index.html, but they don't scale. Purging by tag lets you invalidate 50,000 variants of a product with one API call in ~100-200ms, while wildcard URL purges often take 30-90 seconds and are rate-limited to ~1,000 URLs per minute on most providers. Tag purges are also atomic — you won't get a half-purged state where some variants are fresh and others aren't.

What's the right TTL to pair with my taxonomy?

Match TTL to change frequency, not file type. Immutable assets with content hashing (e.g., app.a3f9c2.js) can be 1 year at edge and browser. Mutable product data: 1 hour to 24 hours with stale-while-revalidate of 50% of TTL. High-churn marketing assets or pricing JSON: 60-300 seconds. Don't set everything to 86400 because it's easy — you'll either serve stale prices for a day or purge constantly, which defeats the purpose of caching.

Why is content still stale after a successful purge API response?

A 200 OK from the purge API means the purge was accepted, not fully propagated. Check three things: 1) Browser cache — your CDN-Cache-Control doesn't override Cache-Control in the browser unless you separate them. 2) Shield or origin cache — you may have purged edge but not shield. Purge with global scope, not PoP-local. 3) Surrogate key attachment — hit the object with a debug header and verify the Surrogate-Key is actually present. If it's missing, your origin or WAF stripped it before it reached the edge.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker