Caching — Browser, CDN & Server

intermediate caching cdn redis performance cache-control

Caching is storing a copy of data somewhere closer (or faster) so we don’t have to fetch it from the original source every time. It’s the single most impactful performance optimization we can make. A well-cached app can handle 100x the traffic with the same hardware.

The Caching Layers

Every request can be cached at multiple points along the way. Each layer closer to the user is faster.

Browser Cache
~0ms • on user's machine
miss?
CDN Edge Cache
~20ms • nearest edge server
miss?
Server Cache (Redis)
~1ms • in-memory on server
miss?
Database
~10-100ms • disk read + query
source of truth

If the browser has a cached copy, we skip the entire network. If the CDN has it, we skip our server entirely. If Redis has it, we skip the slow database query. Every cache hit is a shortcut.

Browser Cache (HTTP Caching)

The browser decides whether to cache a response based on HTTP headers the server sends back. These are the headers that matter.

Cache-Control

This is the most important caching header. It tells the browser exactly what to do.

# Cache for 1 year (static assets like JS, CSS, images)
Cache-Control: public, max-age=31536000, immutable

# Cache but always check with the server first (API responses)
Cache-Control: no-cache

# Never cache at all (sensitive data like bank pages)
Cache-Control: no-store
  • max-age=N — cache for N seconds, don’t even ask the server during that time
  • public — any cache (browser, CDN, proxy) can store this
  • private — only the user’s browser can cache this (not CDN)
  • no-cache — cache it, but always validate with the server before using it
  • no-store — don’t cache at all, period
  • immutable — this file will never change (combined with cache-busted filenames)

ETag and 304 Not Modified

When the cache expires (or no-cache is set), the browser asks the server “has this changed?” instead of downloading the whole thing again.

# First request
GET /api/users
# Server responds with the data + an ETag (a fingerprint of the content)
200 OK
ETag: "abc123"
Content: [the full response]

# Later request — browser sends the ETag back
GET /api/users
If-None-Match: "abc123"

# If nothing changed, server responds with just a status (no body!)
304 Not Modified
# Browser uses its cached copy — saved bandwidth

This is called conditional validation. The Last-Modified / If-Modified-Since headers work the same way, but with timestamps instead of fingerprints.

CDN Caching

A CDN (Content Delivery Network) is a network of servers spread across the globe. When a user in Tokyo requests our site hosted in New York, the CDN serves the cached copy from a server in Tokyo — cutting latency from ~200ms to ~20ms.

How CDN caching works:

  1. User requests style.css from our CDN URL
  2. CDN edge server checks: “do I have a cached copy?”
  3. If yes → return it immediately (cache hit)
  4. If no → fetch from our origin server, cache it, then return it (cache miss)
  5. Future requests to that edge get the cached copy
# Typical CDN cache headers on a response
Cache-Control: public, max-age=86400   # CDN caches for 24 hours
X-Cache: HIT                           # tells us this came from CDN cache
CF-Cache-Status: HIT                   # Cloudflare-specific header

Cache Invalidation at CDN Level

When we deploy new code, we need the CDN to stop serving the old version. Two approaches:

  • Cache busting — change the filename: style.abc123.css. Build tools do this automatically (Vite, Webpack add content hashes). The old filename is never requested again, so the old cache naturally expires.
  • Purge API — tell the CDN to drop its cache: curl -X POST https://api.cloudflare.com/purge_cache. Useful for API responses or HTML pages where we can’t change the URL.

Server-Side Caching

When the database is the bottleneck, we cache query results in memory on the server.

Redis — an in-memory key-value store. We store frequently accessed data (user sessions, API responses, computed results) in Redis. Reading from Redis takes ~1ms. Reading from a database takes 10-100ms.

# Conceptual flow (pseudocode)
# 1. Check Redis first
cached = redis.get("user:42")
if cached:
    return cached           # ~1ms, skip the database

# 2. Cache miss — hit the database
user = db.query("SELECT * FROM users WHERE id = 42")  # ~30ms
redis.set("user:42", user, ex=300)  # cache for 5 minutes
return user

In-memory caching — sometimes we don’t even need Redis. A simple object or Map in our application process works for small datasets that rarely change (like configuration, feature flags, or country lists).

Cache Invalidation Strategies

The hardest problem in caching: knowing when to throw away the cached copy.

TTL (Time To Live) — set an expiration time. After 5 minutes, the cache entry is stale and gets refreshed. Simple, but data could be outdated for up to 5 minutes.

Cache busting with versioned URLs — append a version or hash to the URL: style.css?v=123 or style.abc123.css. When the content changes, the URL changes, so the browser treats it as a new resource.

Key-based invalidation — when we update user 42 in the database, we explicitly delete the Redis key user:42. The next request will miss the cache and fetch fresh data.

Write-through cache — every time we write to the database, we also update the cache. Keeps cache and database always in sync, but adds complexity.

Common Pitfalls

Stale data — caching an API response for 1 hour means users might see outdated info for up to 1 hour. For financial or real-time data, this is unacceptable. Always match TTL to how stale the data can be.

Cache stampede (thundering herd) — imagine 10,000 users hit the same uncached endpoint at the same time. All 10,000 requests miss the cache and slam the database simultaneously. Solutions: lock the cache so only one request fetches from the database while others wait, or pre-warm the cache before it expires.

Caching authenticated data publicly — if we set Cache-Control: public on a response that contains user-specific data, the CDN might serve one user’s data to another. Always use private for personalized responses.

# Good: static assets everyone sees
Cache-Control: public, max-age=31536000, immutable

# Good: API response specific to a user
Cache-Control: private, no-cache

# Dangerous: never cache login pages or sensitive data
Cache-Control: no-store

In simple language, caching stores copies of data closer to where it’s needed — in the browser, at CDN edges, or in Redis on the server — so we skip slow network trips and database queries, making everything dramatically faster.