Why Caching Matters
Caching is the fastest code you'll ever write, because it's code that doesn't run. Done well, a cache turns a 200ms database query into a sub-millisecond memory lookup — for every user after the first. The hard part isn't storing data; it's knowing when to throw it away.
The fundamental trade-off: caching improves performance but introduces the risk of serving stale data. The art of caching is choosing what to cache, how long to cache it, and when to invalidate it.
Layer 1: Browser Caching
The fastest cache is the one closest to the user — their browser. HTTP cache headers tell browsers how long to store responses before requesting them again.
Cache-Control Header
# Cache for 1 year (static assets with content hashing) Cache-Control: public, max-age=31536000, immutable # Cache for 5 minutes, revalidate afterward Cache-Control: public, max-age=300 # Never cache (user-specific or frequently changing data) Cache-Control: no-store # Cache but always revalidate (check if still fresh) Cache-Control: no-cache
ETags and Conditional Requests
ETags allow efficient revalidation. The server sends an ETag (a hash of the content). On subsequent requests, the browser sends the ETag back. If content hasn't changed, the server returns 304 Not Modified with no body — saving bandwidth while ensuring freshness.
# Server response ETag: "abc123def456" Cache-Control: no-cache # Browser's subsequent request If-None-Match: "abc123def456" # Server response if unchanged HTTP/1.1 304 Not Modified
Cache Busting with Content Hashing
For static assets (CSS, JavaScript, images), set a very long cache duration and include a content hash in the filename. When the file changes, the filename changes, forcing browsers to fetch the new version:
# Old filename: main.css → new: main.a3f9b2.css <link rel="stylesheet" href="/assets/main.a3f9b2.css"> # Set max cache time — safe because filename changes with content Cache-Control: public, max-age=31536000, immutable
Layer 2: CDN Caching
A Content Delivery Network caches responses at edge servers around the world. When a user in Tokyo requests your content, they get it from a CDN node in Tokyo rather than your origin server in Frankfurt. CDN caching is configured through the same Cache-Control headers — CDNs respect public directives.
Key CDN caching concepts:
- Cache-Control: public — CDNs can cache this response
- Cache-Control: private — User-specific, CDN must not cache
- Surrogate-Key / Cache-Tag: Proprietary headers (Cloudflare, Fastly) for tag-based cache purging — purge all responses tagged "product-123" at once
- Vary header: Tells caches to store separate responses for different request headers (e.g., Vary: Accept-Encoding for gzip vs brotli)
Layer 3: Application-Level Caching
For dynamic data that doesn't benefit from HTTP caching, application-level caches store computed results in memory or a dedicated cache store like Redis.
In-Memory Cache (Single Server)
// Simple in-memory cache with TTL (Node.js) const cache = new Map(); function getCached(key, ttlMs, fetchFn) { const entry = cache.get(key); if (entry && Date.now() - entry.timestamp < ttlMs) { return entry.value; // cache hit } const value = await fetchFn(); cache.set(key, { value, timestamp: Date.now() }); return value; } // Usage const user = await getCached( `user:${userId}`, 5 * 60 * 1000, // 5 minute TTL () => db.query('SELECT * FROM users WHERE id = ?', [userId]) );
Redis: Distributed Cache
In-memory caches on a single server don't scale horizontally and are lost on restart. Redis is an in-memory data store that works as a distributed cache shared across multiple application servers:
import redis from 'redis'; const client = redis.createClient(); // Cache a database query result for 5 minutes async function getUserById(id) { const cacheKey = `user:${id}`; // Try cache first const cached = await client.get(cacheKey); if (cached) return JSON.parse(cached); // Cache miss: fetch from database const user = await db.findUser(id); // Store in Redis with 5-minute expiry await client.setEx(cacheKey, 300, JSON.stringify(user)); return user; }
Cache Invalidation Patterns
Phil Karlton famously said: "There are only two hard things in computer science: cache invalidation and naming things." How do you ensure users see fresh data?
Time-Based Expiration (TTL)
The simplest strategy: cached values automatically expire after a fixed duration. Good for data that changes infrequently and where brief staleness is acceptable. The downside: users might see stale data for up to the TTL duration after a change.
Cache-Aside (Lazy Loading)
The application checks the cache first. On a miss, it loads from the database and populates the cache. On a write, it invalidates (or updates) the relevant cache entries. This is the most common pattern for database caching.
Write-Through Cache
Every database write also writes to the cache. Cache is always consistent with the database. The downside is slower writes (two writes instead of one) and potential cache pollution for data that's rarely read.
Event-Driven Invalidation
When a product is updated, publish a "product.updated" event. Cache subscribers listen for this event and immediately invalidate the relevant cache entries. This approach keeps the cache fresh without TTL staleness, but adds complexity.
What Not to Cache
- User-specific data in shared caches: Never store user A's data in a shared cache that user B could access. Use user-scoped cache keys.
- Sensitive information: Passwords, tokens, PII — don't cache without encryption.
- Highly volatile data: Real-time stock prices, live sports scores — caching adds latency without benefit.
- Frequently unique requests: If every request has a different parameter combination, the cache hit rate will be near zero.
When a popular cache key expires, multiple requests may simultaneously find a cache miss and all hit the database at once — the "thundering herd" or cache stampede. Solutions include probabilistic early expiration (start refreshing before expiry), mutex locking (only one request refreshes, others wait), or stale-while-revalidate (serve stale content while refreshing in the background).
Conclusion
Effective caching is layered: browser caching eliminates network round trips for repeat visitors, CDN caching serves global users with low latency, and application-level caching reduces database load for dynamic data. Start with HTTP cache headers for static assets — these are easy wins. Add application caching for expensive queries or API calls. Always consider the invalidation strategy before adding a cache. A cache that serves stale data is often worse than no cache at all, so design your invalidation approach as carefully as your caching approach.