Caching Strategies for Web Applications

Caching is one of the most effective tools for improving web application performance. A well-designed caching strategy can reduce latency by orders of magnitude and dramatically cut infrastructure costs — but caching the wrong things causes subtle, hard-to-debug bugs.

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.
The Cache Stampede Problem

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.