Designing a Content Delivery Network: Edge Caching, Cache Invalidation, and Origin Shield Patterns
How CDNs work under the hood: edge caching strategies (TTL, stale-while-revalidate, cache keys), cache invalidation patterns (purge, tag-based, surrogate keys), origin shield as an intermediate cache tier, consistent hashing for cache distribution, and TypeScript examples for custom cache logic with Cloudflare Workers.
Most engineers interact with a CDN by setting a Cache-Control header and moving on. That works until you need to purge a million cached responses in under a second, handle a flash sale that spikes origin load by 50x, or debug why users in Frankfurt are seeing data from three hours ago. At that point the abstraction breaks and you need to understand what is actually happening at the edge.
This article covers CDN internals: how edge caching works, the tradeoffs in cache key design, the three main invalidation strategies, how origin shield protects your origin from cache bypass traffic, and how consistent hashing distributes cache storage across edge nodes. TypeScript examples use Cloudflare Workers, which gives you direct access to the cache API at the edge.
The Problem CDNs Solve
Two problems drive CDN adoption: latency and origin load.
Latency is physical. A request from Tokyo to a US-East origin has ~150ms of round-trip time before any application logic runs. An edge node in Tokyo can serve a cached response in under 5ms. For a page that makes five serial requests, that difference is a second of perceived load time.
Origin load is a capacity problem. Without a CDN, every request hits your origin. A product launch that drives 50,000 concurrent users means 50,000 concurrent connections to your API servers. With a CDN serving an 80% cache hit ratio, your origin only sees 10,000 requests. The other 40,000 are served from edge nodes without touching your infrastructure.
Where CDNs break down: dynamic, user-specific content. A CDN cache is a shared cache. A response that includes a user’s account balance cannot be shared across users. The boundary between cacheable and non-cacheable content is the most important design decision in CDN architecture.
Edge Caching: How Responses Get Stored
Each CDN edge node is a reverse proxy with a large local cache, typically backed by a combination of RAM and NVMe SSD. When a request arrives at an edge node:
- The node computes a cache key from the request (more on this below).
- It checks local storage for a matching entry.
- On a hit, it returns the cached response with a modified
Ageheader indicating how stale the entry is. - On a miss, it forwards the request to the origin (or to an origin shield node), stores the response, and returns it.
This sounds simple. The complexity is in steps 1 and 2: how the cache key is computed, and how the stored entry is validated.
TTL and Cache-Control
The primary mechanism for telling a CDN how long to cache a response is the Cache-Control response header. The relevant directives for edge caches:
public: the response can be cached by shared caches (CDN nodes).s-maxage=N: the CDN should cache this for N seconds. Overridesmax-agefor shared caches.max-age=N: both browser and CDN cache for N seconds (ifs-maxageis not set).no-store: do not cache at all.private: only browser caching; CDN should not cache.stale-while-revalidate=N: serve the stale cached entry while fetching a fresh copy in the background, for up to N seconds after the entry has expired.
// Cloudflare Worker: set differentiated cache headers per content type
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const response = await fetch(request);
const headers = new Headers(response.headers);
if (url.pathname.startsWith("/api/products/")) {
// Product data: 5 min CDN cache, serve stale for 60s while revalidating
headers.set(
"Cache-Control",
"public, s-maxage=300, stale-while-revalidate=60"
);
} else if (url.pathname.startsWith("/api/user/")) {
// User-specific data: never cache at the edge
headers.set("Cache-Control", "private, no-store");
} else if (url.pathname.startsWith("/static/")) {
// Immutable assets: long TTL, browser and CDN
headers.set(
"Cache-Control",
"public, max-age=31536000, immutable"
);
}
return new Response(response.body, {
status: response.status,
headers,
});
},
};
stale-while-revalidate is the most underused directive. Without it, an expired entry causes the user to wait for a full origin round-trip. With it, the first user after expiration gets the stale (but valid) response immediately while the CDN fetches a fresh copy. Subsequent users also get the stale copy until the background fetch completes. For content where a few seconds of staleness is acceptable, this eliminates revalidation latency almost entirely.
Cache Key Design
The cache key determines whether two requests share a cached response. By default, CDN cache keys are the full request URL (scheme + host + path + query string). This works for simple cases but breaks down quickly.
Query string ordering is the first problem. /products?sort=price&page=1 and /products?page=1&sort=price are semantically identical but will be stored as separate cache entries. If your CDN allows custom cache key rules, normalize query string parameter order before caching.
Vary headers are the second problem. If your origin returns Vary: Accept-Encoding, the CDN stores separate cache entries for gzip and brotli responses. If it returns Vary: Cookie, the CDN stores a separate entry for each unique cookie combination. A Vary: Cookie on a response with any session cookie effectively disables caching for authenticated requests.
// Cloudflare Worker: custom cache key that normalizes query params
// and strips tracking parameters
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
// Remove tracking params that do not affect content
const trackingParams = ["utm_source", "utm_medium", "utm_campaign", "fbclid"];
trackingParams.forEach((p) => url.searchParams.delete(p));
// Normalize: sort remaining query params
url.searchParams.sort();
// Build a clean cache key
const cacheKey = new Request(url.toString(), {
method: request.method,
headers: request.headers,
});
const cache = caches.default;
const cached = await cache.match(cacheKey);
if (cached) return cached;
// Fetch from origin using the original request (preserve tracking params for analytics)
const response = await fetch(request);
// Store under the normalized key
if (response.ok) {
await cache.put(cacheKey, response.clone());
}
return response;
},
};
Stripping tracking parameters from cache keys is one of the highest-leverage CDN optimizations available. A single Facebook ad campaign can generate hundreds of unique cache keys from a URL that should have one.
Cache Invalidation Patterns
Three patterns cover the space from simple to surgical.
Pattern 1: TTL Expiration
Let entries expire naturally. No active invalidation. This is the lowest operational complexity option and the right default for content that changes on a predictable schedule.
Where TTL breaks down: you publish a critical correction to a blog post and it takes 10 minutes to propagate. You update product pricing and customers see stale prices for the duration of the TTL. For content where freshness matters within seconds, TTL alone is insufficient.
Pattern 2: URL Purge
Send a purge request to the CDN API for a specific URL. The CDN removes that entry from all edge nodes.
// Purge a specific URL from Cloudflare's cache
async function purgeUrl(url: string): Promise<void> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ files: [url] }),
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(`Purge failed: ${JSON.stringify(error)}`);
}
}
// Call this from your CMS webhook or admin action
await purgeUrl("https://example.com/products/widget-pro");
URL purge works when the mapping from data change to affected URLs is 1:1. It breaks down when one data change affects many URLs. A product category update might affect hundreds of product listing pages, paginated across dozens of URLs. Purging each one individually is slow, error-prone, and burns API rate limits.
Pattern 3: Tag-Based Purge (Surrogate Keys)
Tag-based purge is the production-grade approach for complex content graphs. Each cached response is tagged with one or more labels. When data changes, you purge by tag, and the CDN invalidates every entry carrying that tag across all edge nodes.
Cloudflare calls these Cache-Tag headers. Fastly calls them Surrogate-Key headers. Varnish implements them as X-Cache-Tags. The concept is identical.
// Origin server: tag responses with the entities they depend on
app.get("/products/:id", async (req, res) => {
const product = await getProduct(req.params.id);
const category = await getCategory(product.categoryId);
res
.set("Cache-Control", "public, s-maxage=3600, stale-while-revalidate=300")
// Tag with all entities this response depends on
.set(
"Cache-Tag",
[
`product:${product.id}`,
`category:${product.categoryId}`,
`brand:${product.brandId}`,
].join(",")
)
.json(product);
});
app.get("/categories/:id/products", async (req, res) => {
const products = await getProductsByCategory(req.params.id);
res
.set("Cache-Control", "public, s-maxage=600")
// All products in this response carry the category tag
.set("Cache-Tag", `category:${req.params.id}`)
.json(products);
});
// Purge everything tagged with a category when that category's data changes
async function purgeByTag(tag: string): Promise<void> {
const response = await fetch(
`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CF_API_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ tags: [tag] }),
}
);
if (!response.ok) {
throw new Error("Tag purge failed");
}
}
// Product updated: purge product tag and its category tag
async function onProductUpdated(product: Product): Promise<void> {
await Promise.all([
purgeByTag(`product:${product.id}`),
purgeByTag(`category:${product.categoryId}`),
]);
}
Tag-based purge decouples cache invalidation from URL structure. When a brand changes its logo, you purge brand:42 and every response that included that brand’s data is invalidated instantly, regardless of how many URLs it appeared on. This is the pattern used by large e-commerce platforms and CMSs that serve millions of cached pages.
The tradeoff: Cloudflare’s tag purge API can handle up to 30 tags per request and propagates to all edge nodes within ~150ms globally. Some CDNs charge extra for tag-based purge (Fastly’s instant purge is a paid feature). And you are responsible for keeping the tag mapping accurate. A response missing a relevant tag will not be purged when that entity changes.
Origin Shield
Origin shield is a dedicated intermediate cache tier sitting between edge nodes and your origin. Without origin shield, every edge node that misses its local cache makes an independent request to your origin. With origin shield, cache misses from all edge nodes are funneled through a single shield node. If the shield has the response cached, the origin sees nothing.
The practical difference: for a CDN with 300 edge nodes and a cache miss rate of 10%, origin shield can reduce origin traffic by two orders of magnitude. Instead of potentially 30 simultaneous requests to the origin for the same cache miss, only one goes through.
User request (Frankfurt edge) -> miss -> Origin Shield (Amsterdam) -> miss -> Origin
User request (London edge) -> miss -> Origin Shield (Amsterdam) -> hit -> cached
User request (Paris edge) -> miss -> Origin Shield (Amsterdam) -> hit -> cached
In Cloudflare’s architecture, origin shield is called “Tiered Cache.” In Fastly, it is called “Shield.” In AWS CloudFront, it is “Origin Shield.”
// Cloudflare Worker: implement a manual shield pattern
// Route cache misses from edge to a designated shield datacenter
const SHIELD_DATACENTER = "AMS"; // Amsterdam
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const cache = caches.default;
const cacheKey = new Request(request.url, { method: "GET" });
// Check local edge cache
const edgeCached = await cache.match(cacheKey);
if (edgeCached) return edgeCached;
// Check if we are the shield datacenter
const cf = (request as any).cf;
if (cf?.colo === SHIELD_DATACENTER) {
// We are the shield: go to origin
const originResponse = await fetch(request, {
cf: { cacheEverything: false }, // bypass Cloudflare's automatic caching for this subrequest
});
if (originResponse.ok) {
ctx.waitUntil(cache.put(cacheKey, originResponse.clone()));
}
return originResponse;
}
// We are an edge node: route miss to the shield
const shieldUrl = new URL(request.url);
const shieldRequest = new Request(shieldUrl.toString(), {
headers: {
...Object.fromEntries(request.headers),
"X-Shield-Request": "1",
},
});
// Fetch from shield (Cloudflare's Argo Smart Routing handles the path)
const shieldResponse = await fetch(shieldRequest);
if (shieldResponse.ok) {
ctx.waitUntil(cache.put(cacheKey, shieldResponse.clone()));
}
return shieldResponse;
},
};
The main tradeoff for origin shield: you are adding a hop. Requests that would have been a cache hit at the edge are unaffected. Requests that miss the edge but hit the shield pay an extra 20-50ms for the shield round-trip. Requests that miss both pay for two hops before reaching the origin. Whether that tradeoff is worth it depends on your origin capacity. If your origin can handle the miss traffic comfortably, skip the shield. If a cache miss storm can saturate your origin, origin shield is not optional.
Consistent Hashing for Cache Distribution
CDN edge nodes use consistent hashing to decide which node in a cluster is responsible for a given cache key. The core property: when a node is added or removed from the cluster, only 1/n of the keyspace is remapped (where n is the number of nodes). This minimizes cache invalidation when the cluster topology changes.
// Consistent hash ring implementation used in CDN node selection
class ConsistentHashRing {
private ring: Map<number, string> = new Map();
private sortedKeys: number[] = [];
private readonly replicationFactor: number;
constructor(replicationFactor = 150) {
// Virtual nodes per physical node; higher = more even distribution
this.replicationFactor = replicationFactor;
}
addNode(nodeId: string): void {
for (let i = 0; i < this.replicationFactor; i++) {
const virtualKey = this.hash(`${nodeId}:${i}`);
this.ring.set(virtualKey, nodeId);
}
this.sortedKeys = Array.from(this.ring.keys()).sort((a, b) => a - b);
}
removeNode(nodeId: string): void {
for (let i = 0; i < this.replicationFactor; i++) {
const virtualKey = this.hash(`${nodeId}:${i}`);
this.ring.delete(virtualKey);
}
this.sortedKeys = Array.from(this.ring.keys()).sort((a, b) => a - b);
}
getNode(key: string): string {
if (this.ring.size === 0) throw new Error("Ring is empty");
const keyHash = this.hash(key);
// Walk clockwise to find the next virtual node
const idx = this.sortedKeys.findIndex((k) => k >= keyHash);
const target = idx === -1 ? this.sortedKeys[0] : this.sortedKeys[idx];
return this.ring.get(target)!;
}
private hash(value: string): number {
// FNV-1a 32-bit hash (fast, reasonable distribution)
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = (hash * 16777619) >>> 0;
}
return hash;
}
}
// Usage: route cache reads/writes to the consistent node
const ring = new ConsistentHashRing();
ring.addNode("edge-node-1");
ring.addNode("edge-node-2");
ring.addNode("edge-node-3");
const cacheKey = "/products/widget-pro";
const responsibleNode = ring.getNode(cacheKey);
// Always routes to the same node until topology changes
The virtual node count (replication factor) controls distribution uniformity. With 3 physical nodes and 1 virtual node each, one node might own 50% of the keyspace. With 150 virtual nodes per physical node, the distribution approaches uniform. 100-200 virtual nodes per physical node is a common production setting.
Where consistent hashing matters for CDN design: within a cluster of nodes at the same PoP (point of presence), consistent hashing ensures that a given URL is cached on the same node. This maximizes cache hit rate within the cluster. Without it, a cluster of 10 nodes each independently caches the same popular object, wasting memory and amplifying origin load.
Cache Hit Ratio vs. Freshness: The Core Tradeoff
Cache hit ratio and content freshness pull in opposite directions. Every increase in TTL improves hit ratio and worsens worst-case staleness. Every decrease in TTL improves freshness and reduces hit ratio. There is no free lunch.
| Dimension | Long TTL | Short TTL |
|---|---|---|
| Cache hit ratio | Higher | Lower |
| Worst-case staleness | TTL duration | TTL duration |
| Origin load | Lower | Higher |
| Purge urgency | High (long to recover naturally) | Low (expires soon anyway) |
| Operational complexity | Requires purge on change | TTL alone may be sufficient |
The production answer is to segment by content type and set TTLs based on acceptable staleness per type:
- Static assets (JS, CSS, images with content-hash filenames):
max-age=31536000, immutable. One year. The filename changes when content changes, so the URL changes, which is a natural cache bust. - API responses for public, slow-changing data (product catalog, blog posts):
s-maxage=300, stale-while-revalidate=60. Five minutes with background revalidation. - API responses for public, fast-changing data (inventory counts, prices):
s-maxage=10, stale-while-revalidate=5. Ten seconds. Combined with tag-based purge on update. - User-authenticated responses:
private, no-store. Never cache at the edge. - HTML pages: tricky. If the HTML embeds user state (cart count, user name), treat as authenticated. If fully public, use the same TTL as the API it depends on and ensure purge tags match.
// Cloudflare Worker: apply cache policy based on content type and route
function getCachePolicy(url: URL): string {
const path = url.pathname;
// Immutable static assets with hash in filename
if (/\.[a-f0-9]{8,}\.(js|css|woff2|png|jpg|webp)$/.test(path)) {
return "public, max-age=31536000, immutable";
}
// Public API: product catalog
if (path.startsWith("/api/products") || path.startsWith("/api/categories")) {
return "public, s-maxage=300, stale-while-revalidate=60";
}
// Public API: inventory or pricing (fast-changing)
if (path.startsWith("/api/inventory") || path.startsWith("/api/pricing")) {
return "public, s-maxage=10, stale-while-revalidate=5";
}
// Authenticated routes
if (path.startsWith("/api/user") || path.startsWith("/api/orders")) {
return "private, no-store";
}
// Default: short public cache
return "public, s-maxage=60";
}
export default {
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const response = await fetch(request);
const headers = new Headers(response.headers);
headers.set("Cache-Control", getCachePolicy(url));
return new Response(response.body, {
status: response.status,
headers,
});
},
};
Production Considerations
Observability. The most important cache metric is not hit ratio in isolation, but hit ratio by route. A 95% overall hit ratio can hide a 30% hit ratio on your highest-traffic endpoint. Instrument CF-Cache-Status (or the equivalent header for your CDN) per route and alert when hit ratio drops below threshold.
Cache stampede at the edge. When a high-traffic cached response expires, hundreds of edge nodes simultaneously miss and simultaneously request the origin. Even with origin shield, the shield node itself can be stampeded. stale-while-revalidate prevents this in most cases: the expired entry keeps serving until the revalidation completes. For entries that cannot serve stale (financial data, inventory), implement request coalescing at the origin shield layer: the shield node should hold concurrent miss requests and fan out a single origin request.
Purge propagation time. CDN purge APIs guarantee eventual propagation, not instantaneous. Cloudflare’s tag purge propagates globally in under 150ms under normal conditions. During a CDN incident, propagation can take seconds to minutes. For time-sensitive invalidation (security patches, legal takedowns), plan for purge retries and monitor propagation via a canary request from multiple regions.
Cache key collisions. If two distinct resources hash to the same cache key, you serve one in place of the other. This is usually a cache key design bug rather than a hash collision. The most common cause: normalizing query parameters too aggressively and losing meaningful variation. Test cache key logic explicitly and verify that a request for /products?color=red does not return a cached response for /products?color=blue.
Versioning cached API responses. When you deploy a breaking change to an API response shape, cached responses from before the deploy will be served to clients expecting the new shape. Two approaches: include an API version in the URL (e.g., /api/v2/products), which treats old and new as separate cache namespaces; or purge all affected cache entries as part of the deploy process. The URL versioning approach is more reliable and does not depend on purge propagation completing before traffic shifts.
Closing
CDN architecture is applied distributed systems. The same patterns that appear in database caching (TTL, invalidation, consistent hashing, stampede prevention) reappear at the network edge, with the added complexity of global distribution and multi-hop cache hierarchies.
The engineers who get the most value from a CDN are not the ones who set a high TTL and walk away. They are the ones who instrument cache hit ratio by route, design cache keys that maximize sharing without sacrificing correctness, use tag-based purge to keep TTLs long and content fresh simultaneously, and put origin shield in front of anything that cannot absorb a miss storm.
The cache hit ratio is a lagging indicator. The design decisions that determine it are made upfront in how you structure your URLs, your cache keys, and your Cache-Control headers. Get those right and the CDN handles the rest.
More in System Design
How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.
How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.
How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.
How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.