Phil Karlton said there are two hard problems in computer science: naming, cache invalidation, and off-by-one errors. The joke is old because the invalidation problem is real. But that doesn't mean you should avoid caching. It means you need a strategy.

The three levels of web caching

A typical web application has three points where caching helps:

  • CDN (edge cache): static cache closest to the user. Images, CSS, JS bundles, pre-rendered pages.
  • Application cache: in-memory or Redis cache for data that changes infrequently. Sessions, settings, frequent queries.
  • Database cache: query results, prepared statements, materialized views.

Each level has different invalidation characteristics. CDN is the most aggressive (long TTL, manual invalidation). Application cache is the most flexible (short TTL, event-driven invalidation). Database cache is the most conservative (automatic invalidation via triggers or materialized views).

Redis: the application cache workhorse

Redis is fast because it keeps data in memory. It's the right choice for data that needs to be read thousands of times per second.

// Cache-aside pattern
async function getUser(id: string): Promise<User> {
  const cacheKey = `user:${id}`

  // 1. Try cache
  const cached = await redis.get(cacheKey)
  if (cached) return JSON.parse(cached)

  // 2. Cache miss: fetch from database
  const user = await db.user.findUnique({ where: { id } })
  if (!user) throw new AppError('NOT_FOUND', 404, 'User not found')

  // 3. Store in cache with TTL
  await redis.setex(cacheKey, 3600, JSON.stringify(user))  // 1 hour

  return user
}

The cache-aside pattern is the most common: the code tries cache first, fetches from the database on miss, and stores in cache for the next read. It's simple and works for most cases.

Invalidation strategies

The patterns that work in production:

TTL (Time-To-Live)

The cache expires automatically after a period. Simple, predictable, accepts eventually consistent data.

// Rarely changing data: long TTL
await redis.setex('config:app', 86400, JSON.stringify(config))  // 24h

// Frequently changing data: short TTL
await redis.setex('feed:popular', 300, JSON.stringify(feed))     // 5min

Event-driven invalidation

When the data changes, explicitly invalidate the cache. Requires a domain event that triggers invalidation.

// On user update, invalidate cache
async function updateUser(id: string, data: UpdateUserDto): Promise<User> {
  const user = await db.user.update({ where: { id }, data: data })

  // Invalidate user cache and lists that include them
  await redis.del(`user:${id}`)
  await redis.del('users:active')  // invalidate listing cache

  return user
}

Write-through cache

Updates cache and database in the same operation. Guarantees immediate consistency but adds write latency.

CDN cache: the headers that matter

Controlling CDN cache requires the right headers:

// Static cache: 1 year, immutable
Cache-Control: public, max-age=31536000, immutable

// API with changing data
Cache-Control: private, max-age=0, must-revalidate
ETag: "abc123"

// Page that changes daily
Cache-Control: public, max-age=3600, stale-while-revalidate=86400

stale-while-revalidate allows serving stale content while revalidating in the background. The user gets a fast response, and the content updates without perceptible delay.

Cache warming

After deploy or restart, caches are empty. This causes a latency spike (cold start). Cache warming preloads frequent data before traffic arrives:

// On server startup
async function warmCache() {
  const popularProducts = await db.product.findMany({
    orderBy: { views: 'desc' },
    take: 100
  })

  await Promise.all(popularProducts.map(p =>
    redis.setex(`product:${p.id}`, 7200, JSON.stringify(p))
  ))
}

When not to cache

Not everything needs caching. Data that changes every request (real-time balances), sensitive data without adequate access control, and results of simple, fast queries don't benefit from caching. The overhead of invalidating and maintaining the cache can exceed the performance gain.