Skip to content
January 28, 202612 min readinfrastructure

CDN Strategy: When to Cache, What to Cache, How to Invalidate

A properly configured CDN reduces origin load by 90%+ and cuts TTFB from 800ms to 50ms. Here's the caching strategy that actually works... without serving stale data to your users.

cdncachingperformanceinfrastructurecloudflare
CDN Strategy: When to Cache, What to Cache, How to Invalidate

TL;DR

Cache static assets aggressively (1 year with content hashing). Cache API responses cautiously (stale-while-revalidate patterns). Never cache authenticated responses without Vary headers. Use version strings or tag-based purging... never purge everything. Edge computing beats traditional CDN when you need personalization without sacrificing cache hits. A properly tuned CDN delivers 90%+ cache hit ratios and sub-50ms TTFB globally.

Part of the Performance Engineering Playbook ... from TTFB to TTI optimization.


The Caching Hierarchy

Every request to your application traverses multiple cache layers. Understanding this hierarchy is the difference between serving content in 50ms and 800ms.

Layer 1: Browser Cache

The browser maintains its own cache, controlled by response headers. This is the fastest cache... zero network latency.

Cache-Control: max-age=31536000, immutable

With immutable, the browser won't even revalidate. The asset serves instantly from disk.

Limitation: You control what gets cached, but you can't invalidate it. Once a browser has a cached asset, only its expiration or a URL change will refresh it.

Layer 2: CDN Edge Cache

CDN nodes (Cloudflare, Fastly, CloudFront) cache responses at 300+ locations globally. The nearest edge serves the response... typically 10-50ms latency instead of 100-300ms to origin.

I've seen CDN optimization reduce bandwidth costs by 73% while simultaneously improving TTFB from 800ms to 50ms. The key is understanding what to cache and for how long.

Layer 3: Origin Cache

Your origin server can implement its own cache layer... Redis, Varnish, or application-level caching. This reduces database load but still requires the network round trip to origin.

The pattern: Browser cache for static assets, CDN cache for shared responses, origin cache for expensive computations.


What to Cache: The Decision Framework

Not everything should be cached. Not everything should be uncached. Here's the framework I use.

Tier 1: Cache Aggressively (Static Assets)

These never change for a given URL:

Asset TypeCache DurationStrategy
JS bundles1 yearContent hash in URL
CSS files1 yearContent hash in URL
Images1 yearContent hash or version
Fonts1 yearImmutable
Favicon/manifest1 weekShort TTL, small files
// Next.js automatically handles this // _next/static/chunks/main-[hash].js // Cache-Control: public, max-age=31536000, immutable

The hash changes when content changes. Old URLs are never reused. Cache forever.

Tier 2: Cache with Revalidation (Dynamic but Stable)

Content that changes occasionally but can tolerate brief staleness:

Content TypeCache DurationStrategy
Product listings1-5 minutesstale-while-revalidate
Blog posts1 hourCache tags for invalidation
Marketing pages5 minutesISR (Incremental Static Regen)
API responses30-60 secondsstale-while-revalidate
// stale-while-revalidate pattern // Serve stale content immediately, revalidate in background res.setHeader("Cache-Control", "public, max-age=60, stale-while-revalidate=300");

This header says: serve cached content for 60 seconds, then revalidate in the background while still serving stale content for up to 5 minutes.

Tier 3: Cache Carefully (Personalized Content)

Content that varies by user or session requires special handling:

Content TypeApproach
User dashboardsNo CDN cache, origin cache only
Authenticated APIsVary by Authorization header
A/B test variantsVary by cookie or edge compute
Geo-personalizedEdge compute or Vary by geography
// NEVER cache without Vary header res.setHeader("Cache-Control", "private, max-age=0, must-revalidate"); res.setHeader("Vary", "Authorization, Accept-Encoding");

The Vary header tells CDNs that the response differs based on specific request headers. Without it, User A might see User B's cached dashboard.

Tier 4: Never Cache

Some responses should never touch a cache:

  • Authentication endpoints (/login, /logout, /oauth/*)
  • Payment processing
  • Real-time data (WebSocket fallbacks, live prices)
  • Form submissions (POST/PUT/DELETE responses)
res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); res.setHeader("Pragma", "no-cache");

Cache-Control Deep Dive

The Cache-Control header is the primary mechanism for controlling caching behavior. Understanding its directives is essential.

Header Anatomy

Cache-Control: public, max-age=31536000, immutable ^ ^ ^ | | +-- Never revalidate | +-- Cache for 1 year +-- CDN can cache (not just browser)

Common Directives

DirectiveMeaning
publicAny cache (browser, CDN) can store
privateOnly browser can cache, not CDN
max-age=NCache for N seconds
s-maxage=NCDN cache duration (overrides max-age for CDN)
no-cacheCache but revalidate before using
no-storeDon't cache at all
must-revalidateDon't serve stale content
stale-while-revalidate=NServe stale for N seconds while revalidating
stale-if-error=NServe stale for N seconds if origin fails
immutableContent will never change, don't revalidate

Pattern: Different TTLs for Browser vs CDN

Sometimes you want aggressive CDN caching with shorter browser caching:

// CDN caches for 1 hour, browser for 5 minutes res.setHeader("Cache-Control", "public, max-age=300, s-maxage=3600");

This lets you purge the CDN cache without waiting for browser caches to expire.

Pattern: Resilient Caching

For critical content, serve stale data during origin failures:

res.setHeader( "Cache-Control", "public, max-age=60, stale-while-revalidate=300, stale-if-error=86400" );

If origin is down, the CDN serves day-old content rather than errors.


Implementation: Cloudflare Configuration

Cloudflare is my go-to CDN for most projects. Here's how to configure it properly.

Page Rules (Legacy but Still Works)

# Static assets - cache everything forever URL Pattern: alexmayhew.dev/_next/static/* Cache Level: Cache Everything Edge Cache TTL: 1 month Browser Cache TTL: 1 year # API routes - respect origin headers URL Pattern: alexmayhew.dev/api/* Cache Level: Standard (respect origin) # HTML pages - short cache with revalidation URL Pattern: alexmayhew.dev/* Cache Level: Cache Everything Edge Cache TTL: 1 hour Origin Cache Control: On

Cache Rules (Modern Approach)

// Cloudflare Workers for fine-grained control export default { async fetch(request, env) { const url = new URL(request.url); // Static assets - aggressive caching if (url.pathname.startsWith("/_next/static/")) { const response = await fetch(request); const headers = new Headers(response.headers); headers.set("Cache-Control", "public, max-age=31536000, immutable"); return new Response(response.body, { headers }); } // API routes - stale-while-revalidate if (url.pathname.startsWith("/api/")) { const response = await fetch(request); const headers = new Headers(response.headers); headers.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60"); return new Response(response.body, { headers }); } return fetch(request); }, };

Vercel Configuration

Vercel handles much of this automatically, but you can override:

// pages/api/products.ts export default function handler(req, res) { const products = await getProducts(); // ISR-style caching for API routes res.setHeader("Cache-Control", "public, s-maxage=60, stale-while-revalidate=300"); res.json(products); }
// app/products/page.tsx (App Router) export const revalidate = 60; // Revalidate every 60 seconds async function ProductsPage() { const products = await getProducts(); return <ProductList products={products} />; }

Cache Invalidation Strategies

Phil Karlton said there are only two hard things in computer science: cache invalidation and naming things. Here's how to make invalidation less painful.

Strategy 1: Content Hashing (Best for Static Assets)

Never invalidate... change the URL instead.

// Build output with content hash // main-abc123.js → main-def456.js when content changes // Next.js does this automatically // _next/static/chunks/main-abc123def456.js

Old URLs remain cached forever (no one requests them). New URLs are cache misses that populate fresh. Zero invalidation needed.

Strategy 2: Version Strings (Good for APIs)

Include version in the URL or query parameter:

// Version in path const response = await fetch("/api/v2/products"); // Or version as query param (less clean but works) const response = await fetch("/api/products?v=1643234567");

When you deploy new code, bump the version. Old URLs serve old content (acceptable during rollout); new URLs fetch fresh.

Strategy 3: Cache Tags (Best for Dynamic Content)

Cloudflare, Fastly, and other CDNs support cache tags:

// Tag responses during render res.setHeader("Cache-Tag", "product-123, category-electronics");
# Purge all responses tagged with product-123 curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone}/purge_cache" \ -H "Authorization: Bearer {token}" \ -d '{"tags": ["product-123"]}'

When product 123 changes, purge that tag. All pages containing that product refresh.

Strategy 4: Surrogate Keys (Fastly Pattern)

Similar to cache tags, but Fastly-specific:

res.setHeader("Surrogate-Key", "product-123 category-electronics homepage");
# Purge by surrogate key curl -X POST "https://api.fastly.com/service/{service}/purge/product-123" \ -H "Fastly-Key: {api_key}"

Strategy 5: Time-Based Expiration

The simplest approach... let content expire naturally:

// Content auto-expires after 5 minutes res.setHeader("Cache-Control", "public, max-age=300");

No active invalidation needed. The trade-off is stale content for up to 5 minutes after changes.

What NOT to Do

Never purge everything. A full cache purge:

  • Hammers your origin with traffic
  • Increases latency for all users temporarily
  • Is a sign of poor cache design

If you need to purge everything, your caching strategy is wrong.


Edge Computing vs Traditional CDN

Traditional CDNs cache static responses. Edge computing runs code at the edge, enabling dynamic responses without origin round trips.

When Traditional CDN Wins

  • Static assets (JS, CSS, images)
  • Shared content (blog posts, product pages)
  • Simple personalization (geo-based redirects)

Traditional CDN is simpler and cheaper for these cases.

When Edge Computing Wins

A/B Testing Without Cache Fragmentation

// Cloudflare Worker for A/B testing export default { async fetch(request) { const url = new URL(request.url); // Determine variant at the edge const variant = await getVariant(request); // Fetch from origin with variant header const response = await fetch(url, { headers: { "X-Variant": variant }, }); // Cache per variant const headers = new Headers(response.headers); headers.set("Vary", "X-Variant"); return new Response(response.body, { headers }); }, };

Without edge computing, you'd either fragment your cache (one entry per variant per URL) or give up CDN caching entirely.

Personalization at Scale

// Edge function that personalizes without origin export default { async fetch(request, env) { // Get cached base response const baseResponse = await caches.default.match(request); if (baseResponse) { // Personalize at edge using KV store const userId = getUserIdFromCookie(request); const preferences = await env.USER_PREFS.get(userId); return injectPersonalization(baseResponse, preferences); } return fetch(request); }, };

The base content is cached. Personalization happens at the edge with no origin round trip.

Authentication and Authorization

// Validate JWT at the edge export default { async fetch(request) { const token = request.headers.get("Authorization")?.replace("Bearer ", ""); if (!token || !(await verifyJWT(token))) { return new Response("Unauthorized", { status: 401 }); } // Token valid, proceed with cached response return fetch(request); }, };

Unauthenticated requests are rejected at the edge... origin never sees them.

For a deeper look at edge deployment patterns, see RSC, The Edge, and the Death of the Waterfall.


Cost Optimization

CDN caching directly impacts your infrastructure costs. Here's how to calculate the savings.

Bandwidth Calculation

Assume:

  • 10 million requests/month
  • Average response size: 50KB
  • Origin bandwidth cost: $0.085/GB (AWS)
  • CDN bandwidth cost: $0.02/GB (Cloudflare Enterprise) or $0 (Cloudflare Free/Pro)

Without CDN (0% cache hit):

10M requests × 50KB = 500GB 500GB × $0.085 = $42.50/month origin bandwidth

With CDN (90% cache hit):

1M requests × 50KB = 50GB origin bandwidth 50GB × $0.085 = $4.25/month origin bandwidth 9M requests served from CDN edge = $0 additional (Cloudflare)

Savings: 90% reduction in origin bandwidth costs.

But bandwidth is only part of the equation.

Origin Compute Savings

Each request to origin requires compute:

  • Lambda/serverless: ~$0.20 per million requests + compute time
  • Container/EC2: Provisioned for peak load

With 90% cache hit ratio, you need 90% fewer origin instances.

Example:

  • Without CDN: 4 × m5.large instances ($140/month each) = $560/month
  • With CDN: 1 × m5.large instance = $140/month
  • Savings: $420/month in compute alone

Database Load Reduction

This is often the biggest win. Database connections are expensive... both in licensing (PostgreSQL connections) and performance (connection overhead).

If 90% of requests hit CDN cache, your database sees 90% fewer queries. For a Postgres instance hitting connection limits, this is the difference between scaling vertically ($$$) or not.


Implementation Checklist

Use this checklist when implementing CDN caching for a new project.

Static Assets

  • All JS/CSS bundles have content hashes in filenames
  • Cache-Control: public, max-age=31536000, immutable
  • Images served through CDN with long TTL
  • Fonts cached with CORS headers if cross-origin

API Responses

  • GET endpoints have appropriate Cache-Control headers
  • stale-while-revalidate for non-critical freshness
  • Vary header includes all headers that affect response
  • POST/PUT/DELETE responses are no-store

HTML Pages

  • Static pages cached at edge with ISR or similar
  • Dynamic pages have appropriate short TTL or no-cache
  • Authenticated pages are private or no-store
  • Error pages cached appropriately

Invalidation

  • Cache tags or surrogate keys for dynamic content
  • Version strings for API endpoints if needed
  • Never rely on full cache purge
  • Deployment process includes targeted invalidation

Monitoring

  • Cache hit ratio tracked (target: 85%+)
  • Origin traffic monitored for unexpected spikes
  • TTFB measured from multiple geographic locations
  • Cache invalidation events logged

Security

  • Authenticated content never served from shared cache
  • API keys and tokens not exposed in cached responses
  • Cache poisoning mitigations in place
  • CORS headers properly set for cross-origin assets

Common Mistakes

I've seen CDN configurations go wrong in predictable ways.

Mistake 1: Caching Authenticated Responses

// WRONG - User B might see User A's dashboard res.setHeader("Cache-Control", "public, max-age=3600"); res.json(await getUserDashboard(userId));

Fix: Either private cache or add Vary: Authorization.

Mistake 2: Forgetting Vary Headers

// WRONG - Same URL, different responses, but CDN doesn't know if (req.headers["accept-language"].includes("es")) { res.json(spanishContent); } else { res.json(englishContent); }

Fix: Add Vary: Accept-Language header.

Mistake 3: Cache-Control on POST Responses

// WRONG - Browsers might cache form submissions app.post("/api/submit", (req, res) => { res.setHeader("Cache-Control", "public, max-age=300"); // ... });

Fix: POST responses should be no-store.

Mistake 4: Relying on CDN Defaults

CDN defaults are conservative... often shorter TTLs than optimal. Explicitly set Cache-Control headers on every response.


Measuring Success

Track these metrics to validate your caching strategy:

MetricTargetTool
Cache Hit Ratio85%+Cloudflare Analytics, Fastly Stats
TTFB (P50)Under 100ms globalWebPageTest, Lighthouse
Origin RequestsDown 80%+Origin server metrics
Bandwidth CostDown 70%+Cloud provider billing

If cache hit ratio drops unexpectedly, check for:

  • New query parameters fragmenting cache
  • Cookies being forwarded unnecessarily
  • Vary headers that are too broad

A well-configured CDN is one of the highest-impact infrastructure investments you can make. The difference between 800ms origin requests and 50ms edge responses compounds across every user, every request, every day.

Get the caching strategy right once, and you reduce costs while improving performance. Get it wrong, and you either serve stale data or pay for unnecessary origin traffic.

The decision framework is straightforward: aggressive caching for static assets, careful caching for dynamic content, and never cache what's personalized or authenticated without proper headers.


Need help optimizing your CDN configuration? I help teams implement caching strategies that reduce infrastructure costs while improving global performance.


Continue Reading

This post is part of the Performance Engineering Playbook ... covering Core Web Vitals, database optimization, edge computing, and monitoring.

More in This Series

Need performance optimization? Work with me on your web performance.

Get insights like this weekly

Join The Architect's Brief — one actionable insight every Tuesday.

Need help with performance?

Let's talk strategy