Codetail

Article 13 of 15

CDNs & Edge Networks

Serve content from 50ms away instead of 200ms.

16 min read

How CDNs Work

A Content Delivery Network is a geographically distributed network of cache servers (called Points of Presence, or PoPs) positioned close to end users. When a user requests a resource, the CDN routes the request to the nearest PoP rather than the origin server. If the PoP has a cached copy (a cache hit), it responds immediately. If not (a cache miss), it fetches from the origin, caches the response, and serves it.

The latency reduction is significant. An API origin in us-east-1 has a round-trip time of ~200ms to a user in Sydney. A CDN edge node in Singapore reduces that to ~15ms. For static assets, images, JavaScript bundles, CSS, fonts, the entire request is served from the edge and the origin is never touched. For dynamic content, CDNs can still help by terminating TLS at the edge (saving one full round trip for the TLS handshake) and compressing the response.

CDN Request Flow Visualizer

Asset found at edge PoP, origin never contacted

User (Sydney)
Edge PoP (Singapore)
Origin (us-east-1)
1useredgeGET /logo.png12ms
2edgeuser200 OK (HIT, Age: 3600)16ms

Anycast routing

Most CDNs use anycast IP routing: many edge servers share the same IP address, and the internet's routing protocol (BGP) directs each client to the topologically nearest one automatically. No DNS lookup is needed to find the nearest edge. The routing is transparent to the client, it sends a request to the CDN's IP and receives a response from whatever PoP is closest.

Unicast CDNs (like AWS CloudFront) use DNS-based routing instead: a DNS query for assets.example.com returns the IP of the nearest edge location. This requires a DNS lookup on each new connection but achieves similar geographic routing.

Pull vs push CDN

Pull CDN (most common)

The CDN fetches from origin on the first cache miss and caches the response. No upload step needed. Assets are cached on demand, at the PoPs that actually serve them.

Zero setup for new assets

Only caches what is actually requested

First request per PoP is a cache miss

Used by: Cloudflare, CloudFront, Fastly

Push CDN

You explicitly upload assets to the CDN at deploy time. All PoPs are pre-warmed. No cache misses after deploy. Typically used for large media files and software distribution.

Zero cache misses after push

Requires explicit upload on every deploy

Storage cost at every PoP

Used by: Rackspace Cloud Files, Bunny CDN

Major providers

ProviderPoPsCoverageNotes
Cloudflare300+100+ countriesAnycast network, single IP, nearest PoP serves the request automatically
AWS CloudFront600+90+ citiesDeep integration with S3, ALB, Lambda@Edge. Regional edge caches as mid-tier
Fastly70+GlobalInstant purge (<150ms global propagation), programmable VCL, popular with streaming
Akamai4,000+GlobalLargest network. Enterprise focus. Fine-grained control over routing and security

Cache-Control: Directing CDN Behavior

Cache-Control is the HTTP response header that tells browsers and CDNs how to cache a response. It is a comma-separated list of directives, each controlling a specific aspect of caching behavior. Getting these directives right is the primary lever for CDN cache hit rate and performance.

The most important distinction: some directives apply only to shared caches (CDNs) via the s- prefix, while others apply to all caches including the browser. This lets you cache aggressively at the CDN while controlling what the browser holds locally.

Key directives

max-age=3600Browser + CDN

Cache this response for 3600 seconds (1 hour). Both the browser and CDN respect this.

Cache-Control: max-age=3600
s-maxage=86400CDN only

CDN caches for 86400s (1 day). Browser ignores s-maxage and falls back to max-age. Lets you differentiate CDN TTL from browser TTL.

Cache-Control: max-age=3600, s-maxage=86400
no-cacheBrowser + CDN

Do not serve a cached response without revalidating with the origin first (via ETag or Last-Modified). Misleading name, it does NOT mean 'no caching'.

Cache-Control: no-cache
no-storeBrowser + CDN

Do not cache at all, ever. For sensitive data: bank statements, PII, session-specific pages. Every request goes to origin.

Cache-Control: no-store, private
privateCDN blocked

Response is specific to one user, CDN must not cache it. Browser may cache. Used for authenticated pages, personalized content.

Cache-Control: private, max-age=0
publicBrowser + CDN

Response may be cached by any cache including shared CDN caches, even if the request was authenticated. Explicit permission for CDN caching.

Cache-Control: public, max-age=600
stale-while-revalidate=60Browser + CDN

Serve stale content for up to 60s while fetching a fresh copy in the background. Zero-latency updates, user never waits for revalidation.

Cache-Control: max-age=300, stale-while-revalidate=60
immutableBrowser

The resource will never change for the duration of max-age. Browser skips revalidation entirely. Use only with content-hashed filenames (app.a3f9c.js).

Cache-Control: max-age=31536000, immutable

Caching strategies by asset type

Versioned static assets (app.a3f9c.js, logo.v2.png)

Cache-Control: public, max-age=31536000, immutable

Content hash in filename = unique URL for each version. Safe to cache forever. Browser and CDN will hold until evicted. Zero revalidation overhead.

HTML pages

Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=60

Browser always revalidates (content changes frequently). CDN caches for 5 min, serves stale while updating. Invalidate CDN on deploy.

API responses (public, slow-changing)

Cache-Control: public, s-maxage=60, stale-while-revalidate=30

CDN caches for 60s, serves stale while revalidating. Reduces origin load for popular endpoints. Do not cache if response varies by user.

Authenticated API responses

Cache-Control: private, no-store

User-specific data must never be served from a shared CDN cache. A shared cache returning user A's data to user B is a security incident.

Fonts and icons (third-party or self-hosted)

Cache-Control: public, max-age=31536000, immutable

Fonts do not change. Cache forever. Use font-display: swap in CSS to handle the first-load cache miss without layout shift.

Cache invalidation

Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. The CDN layer makes invalidation concrete. When you deploy new code, CDN edges may still serve the old cached version for hours.

Content-hashed filenames (preferred)

Every build produces filenames with a content hash (app.a3f9c.js). Changing a file changes its URL. Old URL stays cached forever; new URL is fetched fresh. Zero invalidation needed.

Explicit CDN purge API

Trigger a purge via the CDN's API at deploy time. Cloudflare purges globally in under 150ms. CloudFront invalidations take 5-30 seconds. Use path patterns (/assets/*) to batch.

Short TTL with stale-while-revalidate

Set s-maxage=60 and stale-while-revalidate=30. Users see stale content for at most 90 seconds after a deploy, with zero explicit purge needed. Trade-off: more origin requests.

Cache-busting query string

Append ?v=<build_id> to asset URLs. Changes on every deploy. CDN treats different query strings as different cache keys. Simple but leaves orphaned cache entries.

What to Put on a CDN

Not everything belongs on a CDN. The rule is: if the response is the same for every user who requests it, and it is safe to store at a shared cache, it belongs on a CDN. If it is personalized, authenticated, or written by the request, it does not. Misconfiguring cache headers on authenticated content is a security vulnerability, not a performance problem.

Cache on CDN

Static assets with content-hash filenames

Immutable, cache forever. app.a3f9c.js never changes.

Images and video (processed or original)

Large payloads benefit most from geo-proximity. CDNs do on-the-fly resizing and WebP conversion.

Fonts

Loaded on every page. Small files, high frequency. Cache once per PoP, serve forever.

Publicly-cacheable API responses

Product listings, exchange rates, public feeds. Short TTL + stale-while-revalidate works well.

Open Graph images and social previews

Crawled frequently by bots. Expensive to regenerate. Cache with a moderate TTL.

Software downloads and large binaries

CDN offloads the bandwidth cost from your origin. Parallel range requests speed up large downloads.

Do NOT cache on CDN

Authenticated API responses

Cache-Control: private must be honored. Serving user A's data to user B is a security incident.

Session-specific HTML (e.g., /dashboard)

Personalized content varies per user. Cannot be shared across users at a CDN edge.

Responses that vary by cookie or request header without Vary header

Without Vary: Cookie, the CDN serves the first cached variant to all users.

Webhooks and inbound event endpoints

POST endpoints that write data cannot be cached. CDNs terminate TLS but must pass through.

Real-time data (WebSocket, SSE streams)

CDNs do not cache streaming connections. Use them for TLS termination only on these paths.

Security headers via CDN

CDNs are the right place to inject security response headers globally. A single transform rule (Cloudflare Transform Rules, CloudFront Response Headers Policy) adds headers to every response from every origin without any application code change. This ensures headers are applied even on error pages, redirects, and third-party origin responses.

HeaderExample valuePurpose
Strict-Transport-Securitymax-age=63072000; includeSubDomains; preloadForce HTTPS for 2 years. Prevents protocol downgrade attacks.
X-Frame-OptionsDENYPrevent embedding in iframes, blocks clickjacking.
X-Content-Type-OptionsnosniffPrevent MIME-type sniffing. Browser uses declared Content-Type.
Content-Security-Policydefault-src 'self'; ...Define trusted content sources. Most important but complex to configure correctly.
Permissions-Policycamera=(), microphone=()Disable browser features not used by your app.

The Vary header

The Vary header tells the CDN that the response differs based on specific request headers. Without Vary, the CDN caches one response and serves it to all users. With Vary, it maintains separate cache entries per header value combination.

# Response varies by Accept-Encoding (gzip vs br vs none)
Vary: Accept-Encoding

# Response varies by language, CDN caches one version per Accept-Language
Vary: Accept-Language

# DANGER: Vary: Cookie creates a separate cache entry per cookie value
# Effectively disables CDN caching for that resource
# Avoid on public resources
Vary: Cookie

Edge Computing: Logic at the PoP

A CDN PoP was historically a passive cache: it either served a cached response or fetched from the origin. Modern CDNs now allow deploying executable code at every PoP. Cloudflare Workers, AWS Lambda@Edge, and Fastly Compute@Edge run JavaScript or WebAssembly inside the CDN's edge infrastructure, milliseconds from the user.

The model is fundamentally different from serverless functions running in a single region. Edge workers run in V8 isolates (not containers), which start in under a millisecond. They execute at the PoP nearest to the user, so a user in Tokyo runs their request through Tokyo infrastructure rather than waiting for us-east-1. The trade-off is a constrained execution environment: no Node.js APIs, limited CPU time, and no persistent state without external bindings.

What to do at the edge

Authentication and authorization

Verify JWTs at the edge before the request reaches the origin. Reject unauthenticated requests in ~5ms without loading the origin server. Redirect unauthenticated users to login.

Platforms: Cloudflare Workers, Lambda@Edge, Vercel Edge Middleware

A/B testing and feature flags

Assign users to experiment buckets at the edge. Rewrite the URL or modify the response to serve variant A or B. Zero origin round trips for the routing decision.

Platforms: Cloudflare Workers, Fastly Compute@Edge

Geo-based routing and redirects

Inspect the CDN-provided country/region header (CF-IPCountry, CloudFront-Viewer-Country). Redirect EU users to the EU data residency endpoint. Block access from sanctioned regions.

Platforms: All major CDNs, geo headers available natively

Bot detection and rate limiting

Fingerprint requests at the edge before they reach the origin. Check IP reputation, header patterns, and request rates. Return 429 or serve a challenge page without touching the origin.

Platforms: Cloudflare Bot Management, AWS WAF (CloudFront integrated)

Response transformation

Inject headers, modify HTML, add security headers (CSP, HSTS, X-Frame-Options), resize images on the fly. Transform once at the edge for all PoPs simultaneously.

Platforms: Cloudflare Workers, Lambda@Edge (response), Fastly VCL

SSR / ISR at the edge

Render pages at the edge PoP closest to the user using edge-compatible JS runtimes (V8 isolates). Vercel Edge Runtime, Next.js middleware, Cloudflare Pages all support this.

Platforms: Vercel Edge Functions, Cloudflare Pages, Deno Deploy

Edge runtime constraints

!

No Node.js APIs

Edge runtimes run in V8 isolates, not Node.js. The fs, net, and child_process modules are unavailable. Use the Web Platform APIs (fetch, crypto, ReadableStream) instead.

!

Cold start is near-zero, but CPU is limited

Isolates start in under 1ms (no container spin-up). But CPU time per invocation is capped (typically 50ms on Cloudflare Workers). Long-running computations do not belong at the edge.

!

No persistent state (without bindings)

Edge workers are stateless by default. Use KV (eventually consistent), Durable Objects (strongly consistent, but expensive), or D1/Hyperdrive for state that must persist.

!

Global deployment only

Edge workers deploy to all PoPs simultaneously. You cannot deploy to a single region. Rollouts require feature flags in the code, not selective deployment.

Cloudflare Worker, JWT verification at the edge

export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);

    // Public routes bypass auth check
    if (url.pathname.startsWith("/public/")) {
      return fetch(request);
    }

    const auth = request.headers.get("Authorization");
    if (!auth?.startsWith("Bearer ")) {
      return new Response("Unauthorized", { status: 401 });
    }

    const token = auth.slice(7);
    const valid = await verifyJWT(token, env.JWT_PUBLIC_KEY);
    if (!valid) {
      return new Response("Forbidden", { status: 403 });
    }

    // Forward to origin with verified identity header
    const proxied = new Request(request);
    proxied.headers.set("X-User-Id", valid.sub);
    return fetch(proxied);
  }
}