Networking
Part 1 of 6 · CDN & edge cacheCDNs, Cache Hierarchy & Origin Shielding
Edge→mid-tier→shield→origin; s-maxage/SWR; cache keys; purge races; request collapsing.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
- 1
every POP miss → origin
Flat edge, no shield
A viral object expiry worldwide looks like a DDoS. Collapsing at one node does not collapse across POPs.
- 2
Winner: edge → mid-tier → shield → origin
Each tier collapses a different fan-in. The shield is the topology choke point; in-process coalescing is the local one. Pay an extra hop on cold miss to buy origin QPS.
- ?
Keys, SWR, purge, TLS — the knobs that make hierarchy honest
Vary: Cookieexplodes the key. SWR hides revalidation but is not a correctness tool. Hard purge races the shield. Anycast TLS is why the first byte is fast. Those are the sibling pages — this hub stays on the path.
Overview
At senior level, interviewers probe whether you can design for hit ratio and correctness under purge, cookie variance, and origin outages. App-level cache-aside (Redis) is one loop; this lesson closes the loop at edge networking.
By the end you should be able to:
- Draw client → edge POP → mid-tier / regional cache → origin shield → origin and explain what each tier collapses.
- Choose
Cache-Control/s-maxage/stale-while-revalidate/stale-if-errordeliberately for browsers vs shared caches. - Design cache keys that avoid cookie-busting and incorrect
Vary. - Explain purge vs soft purge, and why shielding creates purge races.
- Sketch signed URLs and a purge API client (Python and TypeScript).
- Answer senior questions on Anycast, TLS termination, and request collapsing.
The cache hierarchy
A POP (Point of Presence) is a physical/colo presence where the CDN runs edge servers. Users are steered to a nearby POP via DNS geo / GSLB or Anycast. Inside a POP you typically have many cache nodes; the "edge" is the user-facing cache layer.
| Tier | Role | Typical hit rate | Talks to |
|---|---|---|---|
| Edge (L1) | Closest to user; TLS terminate; WAF; small/hot cache | High for popular objects | Mid-tier or shield on miss |
| Mid-tier / Regional Edge Cache (L2) | Aggregates misses from many edges in a region | Higher than a single edge | Shield or origin |
| Origin Shield (dedicated L2/L3) | Single (or few) designated cache(s) in front of origin | Highest consolidation | Origin only |
| Origin | Authoritative content source | N/A | — |
Tiered cache means lower tiers ask upper tiers before the origin.
- Cloudflare Tiered Cache / Smart Tiered Cache dynamically picks an upper tier close to the origin.
- AWS CloudFront has Regional Edge Caches by default and optional Origin Shield in a chosen region.
- Fastly uses shielding (designate a shield POP).
Benefit: bandwidth efficiency + origin load reduction. Cost: extra hop latency on cold misses; concentration risk if the shield is unhealthy (vendors add shield failover).
Flow
- 1
Client / Browser
- HTTPS Anycast or DNSEdge POP L1: TLS, WAF, cache
- 2
Edge POP L1: TLS, WAF, cache
- HITClient / Browser
- MISS / REVALIDATEMid-tier / Regional Cache L2
- nextClient / Browser
- 3
Mid-tier / Regional Cache L2
- HITEdge POP L1: TLS, WAF, cache
- MISSOrigin Shield
- nextEdge POP L1: TLS, WAF, cache
- 4
Origin Shield
- HITMid-tier / Regional Cache L2
- MISS collapsedOrigin
- nextMid-tier / Regional Cache L2
- 5
Origin
- 200 + Cache-ControlOrigin Shield
Lesson map
CDNs, Cache Hierarchy & Origin Shielding
Edge→mid-tier→shield→origin; s-maxage/SWR; cache keys; purge races; request collapsing.
Architecture. Architecture
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB c["Client / Browser"] e["Edge POP L1: TLS, WAF, cache"] m["Mid-tier / Regional Cache L2"] s["Origin Shield"] c -->|HTTPS Anycast or| e e -->|HIT| c e -->|MISS /| m m -->|HIT| e m -->|MISS| s s -->|HIT| m
Read that as: a HIT returns immediately; a MISS walks up the tree; the origin is reached only after every shared tier missed, and concurrent equivalent misses collapse at the shield.
Origin shielding
Origin Shield = force all origin-bound misses through one shared cache location closest to the origin (not closest to users).
- Collapses geographically distributed misses into one upstream path.
- Improves connection reuse (warm TCP/TLS pools to origin).
- Multi-CDN: one shield (for example CloudFront Origin Shield) can absorb other CDNs' origin traffic if you architect the pull path carefully.
Choose the shield region by lowest latency to origin, not to users. Shielding is a capacity control. Any latency win is a side effect of connection reuse and fewer origin trips.
Deep dive · Why the extra hop is usually worth it
A cold miss pays shield RTT on top of origin RTT. That is the tax. The return is:
- Origin QPS drops from "edges × miss rate" toward "shields × miss rate."
- TLS sessions to origin stay warm.
stale-while-revalidateat the shield hides origin latency from many edges at once.
If the shield POP dies, vendors fail over to another shield or (policy-dependent) go direct to origin. Direct-to-origin during failover is the stampede window — keep stale-if-error long enough to ride it.
Freshness: Cache-Control, s-maxage, SWR
HTTP caching (RFC 9111) is the contract. Browsers and CDNs do not read the same directives the same way. Soft vs hard TTL, SWR coalescing, and stale-if-error during origin 5xx: SWR & stale-if-error. Purge races with the shield: Purge & generation tokens.
| Header / directive | Who honors it | Meaning |
|---|---|---|
max-age | Caches in general (browsers especially) | Freshness lifetime in seconds |
s-maxage | Shared caches (CDN) | Overrides max-age for the CDN |
Surrogate-Control | CDN-specific (Fastly strips before the browser) | Preferred CDN TTL when the vendor supports it |
stale-while-revalidate=N | Caches that implement SWR | After fresh TTL, serve stale up to N seconds while one refresh runs |
stale-if-error=N | Caches that implement SIE | Serve stale if origin is 5xx or unreachable |
Age | All caches | Seconds since origin generated the response — critical with shielding |
ETag / If-None-Match | Origin revalidation | Conditional GET so a miss can still be 304 |
Vary | Caches | Extra cache-key dimensions from request headers |
immutable | Browsers / some CDNs | Versioned asset; do not revalidate until the URL changes |
Practical pattern for HTML/API that must be eventually fresh:
Cache-Control: public, max-age=60, s-maxage=3600, stale-while-revalidate=86400, stale-if-error=604800Browsers refresh often (max-age=60); the CDN holds for an hour; soft stale covers spikes and origin blips.
Deep dive · Age across tiers
Age is how you stop a shield-stale object from being re-cached as fresh at the edge. If the shield stored the object 50 minutes ago and s-maxage is 3600, remaining freshness is 10 minutes — if the edge honors Age. If a purge or TTL override ignores Age, the edge can treat shield-stale bytes as brand new. That is the silent correctness bug behind several "we purged but users still see old HTML" incidents.
Cache keys
Default key ≈ (scheme, host, path, query?) plus selected headers from Vary. Normalization, cookie-busting, and when Vary is the wrong tool: Cache keys & Vary.
Senior pitfalls:
- Including all query params fragments the cache (
utm_*, session ids). Vary: CookieorVary: *→ near-zero hit ratio.- Ignoring normalized path / trailing slash / case → duplicate objects.
- Forgetting device / image format variants (
Accept) when you do need variants — use controlledVaryor separate URLs.
Prefer: whitelist query params in the key; use surrogate keys / tags for purge groups.
Purge and invalidation
| Mechanism | Behavior | Use when |
|---|---|---|
| TTL expiry | Passive | Immutable versioned assets (app.abc123.js) |
| Hard purge | Object gone; next request = miss | Must stop serving immediately |
| Soft purge | Mark stale; SWR/revalidate | Protect origin after bulk updates |
| Surrogate-key / tag purge | Invalidate by content graph | CMS publish (all pages for article X) |
| Purge by prefix | Directory-style (Cloudflare) | Path trees |
Shielding + purge race: edge purged first may refetch from an unpurged shield and re-cache old content. Mitigations: purge twice (Fastly documents this), generation tokens in the cache key, soft purge + careful Age handling.
Never casually hard-purge-all. That is how you buy a thundering herd.
TLS termination and Anycast
TLS. The edge terminates client TLS (short RTT handshake). Origin connections are often long-lived HTTPS (or mTLS) from shield/edge → origin. Handshake budgets, Anycast vs geo-DNS, and key custody: TLS termination & Anycast edge.
- Faster TTFB, WAF inspection, cert management at the CDN.
- Tradeoff: the CDN sees plaintext (trust / compliance). Still pull origin over TLS; that is hop encryption, not end-to-end privacy from the CDN.
Anycast. The same IP is announced from many POPs; BGP delivers packets to a topologically "close" POP.
| Anycast | DNS / GSLB | |
|---|---|---|
| Addressing | One IP | Different answers per resolver |
| Failover | Fast (BGP) | Slower unless TTLs are low |
| Steering | Topological, less control | Finer policy (latency, load, health) |
| Failure mode | BGP ≠ geographic distance | Resolver location and TTL can mis-steer |
Many CDNs use Anycast for DNS + content (Cloudflare) or mix with DNS steering.
Request collapsing (coalescing)
When N concurrent requests miss the same key at one cache node, one fetch goes upstream; waiters share the response. That prevents a local thundering herd. It complements the shield, which collapses across POPs. Single-flight, uncacheable leaders, and in-memory demos: Request collapsing / coalescing.
Caveat: a slow uncacheable leader can inflate waiters' latency — tune timeouts and do not collapse uncacheable responses forever.
Failure modes
Decisions
- 1
Request at Edge
- nextEdge healthy?
- ?
Edge healthy?
- NoAnycast or DNS failover to other POP
- YesFresh HIT?
- 3
Anycast or DNS failover to other POP
- ?
Fresh HIT?
- YesServe 200
- Stale + SWRServe stale + async revalidate
- MISSShield available?
- 5
Serve 200
- 6
Serve stale + async revalidate
- nextPurge race with shield?
- ?
Shield available?
- NoFailover shield or direct origin
- YesShield HIT?
- 8
Failover shield or direct origin
- ?
Shield HIT?
- YesFill edge and serve
- NoOrigin healthy?
- 10
Fill edge and serve
- ?
Origin healthy?
- YesSingle collapsed fetch
- Nostale-if-error?
- 12
Single collapsed fetch
- ?
stale-if-error?
- YesServe stale 200
- No504/502 with retry budget
- 14
Serve stale 200
- 15
504/502 with retry budget
- ?
Purge race with shield?
- Edge purged, shield notRisk: re-cache stale from shield
- 17
Risk: re-cache stale from shield
- nextMitigate: double purge or generation
- 18
Mitigate: double purge or generation
Design for origin outage: long stale-if-error, last-known-good from edge/shield, negative-cache 5xx briefly with care, multi-origin failover for safe GETs, and do not disable collapsing during incidents. Availability can stay high while the freshness SLO slips — say that tradeoff out loud.
Worked examples
Cache-Control helpers are the boring, correct part. Cache keys and collapsing are what interviewers ask you to implement.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Cache-Control builders (teaching sketches)
def build_cache_control(
*,
max_age: int,
s_maxage: int | None = None,
swr: int | None = None,
sie: int | None = None,
public: bool = True,
immutable: bool = False,
) -> str:
"""Browser TTL is max-age; CDN TTL is s-maxage."""
parts = ["public" if public else "private", f"max-age={max_age}"]
if s_maxage is not None:
parts.append(f"s-maxage={s_maxage}")
if swr is not None:
parts.append(f"stale-while-revalidate={swr}")
if sie is not None:
parts.append(f"stale-if-error={sie}")
if immutable:
parts.append("immutable")
return ", ".join(parts)
HTML_CC = build_cache_control(max_age=60, s_maxage=3600, swr=86_400, sie=604_800)
ASSET_CC = build_cache_control(max_age=31_536_000, s_maxage=31_536_000, immutable=True)export function buildCacheControl(opts: {
maxAge: number;
sMaxAge?: number;
swr?: number;
sie?: number;
isPublic?: boolean;
immutable?: boolean;
}): string {
const parts: string[] = [opts.isPublic === false ? "private" : "public"];
parts.push(`max-age=${opts.maxAge}`);
if (opts.sMaxAge !== undefined) parts.push(`s-maxage=${opts.sMaxAge}`);
if (opts.swr !== undefined) parts.push(`stale-while-revalidate=${opts.swr}`);
if (opts.sie !== undefined) parts.push(`stale-if-error=${opts.sie}`);
if (opts.immutable) parts.push("immutable");
return parts.join(", ");
}Signed URLs (not runnable here — HMAC + URL parsing)
Edge verifies exp + sig before serving a private object. Sign the canonical URL so extra query params cannot forge access. Bind expiry + path; compare in constant time.
import hashlib
import hmac
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
def sign_url(base_url: str, *, expires_at: int, secret: bytes) -> str:
message = f"{base_url}|exp={expires_at}".encode()
sig = hmac.new(secret, message, hashlib.sha256).hexdigest()
sep = "&" if "?" in base_url else "?"
return f"{base_url}{sep}exp={expires_at}&sig={sig}"
def verify_signed_url(url: str, *, secret: bytes, now: int, skew_seconds: int = 30) -> bool:
parts = urlsplit(url)
q = parse_qs(parts.query)
try:
exp = int(q["exp"][0])
sig = q["sig"][0]
except (KeyError, ValueError, IndexError):
return False
if now > exp + skew_seconds:
return False
filtered = [(k, v[0]) for k, v in q.items() if k not in {"exp", "sig"}]
base = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(filtered), ""))
expected = hmac.new(secret, f"{base}|exp={exp}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)import { createHmac, timingSafeEqual } from "crypto";
export function signUrl(baseUrl: string, expiresAt: number, secret: string): string {
const message = `${baseUrl}|exp=${expiresAt}`;
const sig = createHmac("sha256", secret).update(message).digest("hex");
const sep = baseUrl.includes("?") ? "&" : "?";
return `${baseUrl}${sep}exp=${expiresAt}&sig=${sig}`;
}
export function verifySignedUrl(
url: string,
secret: string,
now = Math.floor(Date.now() / 1000),
skewSeconds = 30,
): boolean {
const u = new URL(url);
const exp = Number(u.searchParams.get("exp"));
const sig = u.searchParams.get("sig");
if (!exp || !sig) return false;
if (now > exp + skewSeconds) return false;
u.searchParams.delete("exp");
u.searchParams.delete("sig");
const base = u.toString().replace(/\?$/, "");
const expected = createHmac("sha256", secret).update(`${base}|exp=${exp}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(sig);
return a.length === b.length && timingSafeEqual(a, b);
}Signed URLs vs signed cookies: URLs protect individual objects (downloads, HLS segments). Cookies cover path prefixes (a private tree) with one credential. Prefer URLs for one-off assets; cookies for browsing a private section.
Purge API client (not runnable here — network)
Soft purge after CMS publish so SWR can refill without a hard-MISS spike. With shielding, consider a second purge after the shield has taken the first.
import json
import urllib.request
from typing import Iterable
class CDNPurgeClient:
def __init__(self, *, api_base: str, api_token: str, zone_id: str):
self.api_base = api_base.rstrip("/")
self.api_token = api_token
self.zone_id = zone_id
def _request(self, method: str, path: str, body: dict) -> dict:
data = json.dumps(body).encode()
req = urllib.request.Request(
f"{self.api_base}{path}",
data=data,
method=method,
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode())
def purge_urls(self, urls: Iterable[str], *, soft: bool = False) -> dict:
payload: dict = {"files": list(urls)}
if soft:
payload["soft"] = True # vendor-specific flag / Soft-Purge header elsewhere
return self._request("POST", f"/zones/{self.zone_id}/purge_cache", payload)
def purge_prefixes(self, prefixes: Iterable[str]) -> dict:
# Prefix purge is directory-scoped — no query strings in prefixes.
return self._request(
"POST",
f"/zones/{self.zone_id}/purge_cache",
{"prefixes": list(prefixes)},
)export class CDNPurgeClient {
constructor(
private apiBase: string,
private apiToken: string,
private zoneId: string,
) {}
private async request(path: string, body: unknown): Promise<unknown> {
const res = await fetch(`${this.apiBase}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`purge failed: ${res.status}`);
return res.json();
}
purgeUrls(urls: string[], soft = false) {
return this.request(`/zones/${this.zoneId}/purge_cache`, { files: urls, soft });
}
purgePrefixes(prefixes: string[]) {
return this.request(`/zones/${this.zoneId}/purge_cache`, { prefixes });
}
}Interview Q&A
Walk me through a request that misses everywhere.
Answer
Client hits the nearest edge (Anycast or DNS). Edge computes the cache key → MISS. Edge asks mid-tier/regional → MISS. Mid-tier asks origin shield → MISS. Shield collapses concurrent equivalent misses into one origin fetch (conditional GET with If-None-Match if validators exist). Origin returns 200 + Cache-Control / ETag. Shield stores, returns to mid-tier → edge → client, filling each tier. Later requests HIT at the first tier that has a fresh copy. Measure with Age and vendor headers (CF-Cache-Status, X-Cache, Fastly-Restarts).
Edge vs shield — what is the difference from request collapsing?
Answer
Collapsing is local to one cache process: N waiters, 1 upstream fetch for the same key. Origin shield is topology: many edges funnel misses through one shared cache near the origin, collapsing across POPs and regions. You want both. Collapsing alone still lets 200 POPs each send 1 request = 200 origin hits for one object.
How do you invalidate a product page that appears in 50 URLs?
Answer
Do not purge 50 URLs by hand. Emit surrogate keys / cache tags at origin (product:42, category:shoes) and purge by tag on publish. Alternatively content-hash static assets and only purge HTML shells. Soft-purge tags when possible so SWR absorbs the refill. With shielding, account for purge ordering races (double purge or generation bump).
Why might Vary: Cookie destroy your CDN?
Answer
Cookies are high-cardinality and often unique per user or session. The shared cache treats each cookie value as a different object → near-zero hit ratio, origin sees almost every request. Strip cookies on static paths; split static vs personalized hostnames; or whitelist only a stable A/B cookie into the key.
Anycast vs DNS geo-steering?
Answer
Anycast: one IP, BGP picks POP, fast failover, less control, "closest" is topological. DNS/GSLB: different answers per resolver, finer policy (latency, load, health), but TTL and resolver location can mis-steer; failover is slower unless TTLs are low (tradeoff with DNS load). Many designs use both.
Design for origin outage.
Answer
Long stale-if-error, serve last-known-good from edge/shield, negative-cache 5xx briefly with care, multi-origin failover for safe GETs, synthetic health checks, and do not disable collapsing during incidents. Feature-flag nonessential origin calls. SLOs: availability can stay high while freshness slips — communicate that tradeoff.
Signed URLs vs signed cookies?
Answer
Signed URLs protect individual objects (downloads, HLS segments) and work well with CDNs that verify at the edge. Signed cookies cover path prefixes (whole site sections) with one credential. Prefer URLs for one-off assets; cookies for browsing a private tree. Always bind expiry + path; use constant-time compare.
What breaks after a mass purge?
Answer
Thundering herd: every edge MISS stampedes origin unless shield + collapsing + soft purge + SWR are in play. Popular keys that expire together are the same bug — add TTL jitter; prefer soft purge. Watch origin QPS, latency, and error rate; have a purge rate limit and staged invalidation.
max-age vs s-maxage vs stale-while-revalidate?
Answer
max-age is for browsers (and shared caches if s-maxage is absent). s-maxage is for CDNs/shared caches and is the knob you actually want for HTML at the edge. stale-while-revalidate lets a cache serve slightly stale while it refills — hides origin latency. Pair with stale-if-error for origin outages. Quote the headers; do not say “we set caching.”
What belongs in the cache key?
Answer
Scheme + host + normalized path + the query params that change the bytes, plus an allow-listed Vary set (usually Accept-Encoding, maybe a stable A/B cookie). Strip marketing params (utm_*, gclid). Never raw Cookie or Authorization. Normalization bugs are silent origin DDoS.
Should you cache 404s and 301s?
Answer
Yes, briefly, or scanners and broken links become origin load. Negative-cache 404s with a short TTL so a newly published URL is not stuck missing. Cache 301s longer — they are supposed to be stable — but do not cache 302/307 the same way. Never long-cache 5xx.
Pitfalls
| Pitfall | What actually happens |
|---|---|
| Cache stampede at origin | Popular object expires on many edges at once without shield / collapsing / SWR |
| Thundering herd after hard purge | Prefer soft purge + staggered revalidation; enable shield |
| Cookie-busting the cache | Forwarding Cookie or Vary: Cookie on cacheable paths |
Incorrect Vary | Over-varying (raw User-Agent) or under-varying (wrong language/content) |
| Shield purge races | Purged edge refills from unpurged shield; double purge / generation tokens |
Caching Set-Cookie / Authorization | Usually private / no-store unless carefully designed |
| Query string pollution | Every utm_ creates a unique object; whitelist params |
Naive s-maxage + SWR | RFC 9111 ties s-maxage to revalidate semantics; verify CDN; use Surrogate-Control |
| Immutable HTML | Long TTL on unversioned HTML serves wrong content after deploy |
Ignoring Age across tiers | Shield-served stale re-cached as fresh at edge |
Draw client → edge → mid-tier → shield → origin. Mark two collapse points: in-process coalescing at each node, and the shield as a topology choke point. Then add a hard purge at the edge only, and show how the next miss can refill stale from the shield. Write the mitigation (double purge or generation token) on the same diagram.
Cheat sheet
| Concern | Prefer |
|---|---|
| Static hashed assets | Long TTL + immutable |
| HTML / JSON product pages | Short max-age, longer s-maxage, SWR, tag purge |
| Origin protection | Shield + collapsing + soft purge |
| Private downloads | Signed URLs verified at edge |
| Hit ratio | Tight cache keys, strip cookies, whitelist query |
| Incidents | stale-if-error; never hard-purge-all casually |
Go Deeper
Docs
- Cloudflare Tiered Cache
- Cloudflare purge by prefix
- Fastly freshness / HTTP caching semantics
- Fastly stale / SWR
- Fastly purging (including shielding races)
- AWS CloudFront Origin Shield
- RFC 9111 HTTP Caching
Talks
- What Is A CDN? How Does It Work?
- Demystifying CDNs
- Why Your Web Service Needs an Origin Shield (Varnish)
- CDN Architecture: Origin vs Edge
- Cloudflare resilient systems / Anycast
Extra