Phil Karlton joked that computer science has two hard problems: naming, cache invalidation, and off-by-one errors. Invalidation became the excuse for skipping caches, and the caching code still takes ten lines in any service. The rest of the work is picking the key, the TTL, and the point where the copy dies.
Three layers, three invalidation rules
A web application keeps copies of the same data in up to three places:
- CDN (edge): images, CSS, JS bundles, and pre-rendered pages. TTLs measured in days, purged by URL.
- Application cache: Redis or in-memory storage for sessions, settings, and repeated queries. TTLs measured in minutes, keys deleted by code.
- Database: prepared statements, indexes, and materialized views. The database invalidates alongside the write.
You purge the CDN when you publish, run a DEL on the Redis key when the record changes, and let the database handle its own cache. Borrowing a strategy from one layer to another is the shortest path to serving stale data at cache latency.
Cache-aside with Redis
The cache-aside pattern makes the code ask the cache before the database and fill the cache on a miss. The Redis cache-aside guide describes the flow with one production detail: when a popular key expires under load, every reader sees the miss at the same instant and hits the database at once. Stampede protection arrives later, with a Lua lock or probabilistic refresh.
// Cache-aside pattern
async function getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`
// 1. Ask the cache
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// 2. Miss: fetch from the 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 a 1 hour TTL
await redis.setex(cacheKey, 3600, JSON.stringify(user))
return user
}
Measure hit rate before any optimization. A cache at 30% hit rate just moves part of the database load into Redis and doubles the data path you have to observe.
TTL as a safety net
TTL is the invalidation that needs no code. Every key gets a lifetime and Redis EXPIRE drops the key when time runs out. Data that rarely changes gets a long TTL; feeds and rankings get a short one.
// Config changes per deploy: 24h TTL
await redis.setex('config:app', 86400, JSON.stringify(config))
// Ranking recalculated per request: 5min TTL
await redis.setex('feed:popular', 300, JSON.stringify(feed))
A short TTL also covers the forgotten-invalidation bug: the old copy survives at most the TTL. The Amazon ElastiCache guide recommends a TTL on every key, including the ones you update through write-through, precisely to catch those failures.
Invalidation at the write
When the record changes, you delete the affected keys with the update. The cache of the object and the cache of the listing that references it are different keys and die at the same moment:
async function updateUser(id: string, data: UpdateUserDto): Promise<User> {
const user = await db.user.update({ where: { id }, data: data })
// Drop the object and the index that references it
await redis.del(`user:${id}`)
await redis.del('users:active')
return user
}
Write-through stores to database and cache in the same operation. The cost shows up in write latency, because each update becomes two trips. Use write-through for data read right after it is written, such as cart and session; for the rest, delete the key and let the next read repopulate it.
Headers the CDN understands
// Hashed assets: one year
Cache-Control: public, max-age=31536000, immutable
// Authenticated API: always revalidate
Cache-Control: private, max-age=0, must-revalidate
ETag: "abc123"
// Content page: serve stale and revalidate in background
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
stale-while-revalidate comes from RFC 5861 (Nottingham, 2010): the cache returns the stale response and revalidates without blocking the client. RFC 9111 (HTTP Caching, 2022) defines the rest of the negotiation, including the ETag used in If-None-Match. Assets with hashes in their filenames get immutable and a one-year TTL with no risk, because replacing the file produces a new name.
Cache warming after a deploy
A fresh deploy leaves the caches empty and the first read of every item pays the cold start. Preloading the most-read items during startup restores the latency your users expect on the first request:
async function warmCache() {
const popular = await db.product.findMany({
orderBy: { views: 'desc' },
take: 100
})
await Promise.all(popular.map(p =>
redis.setex(`product:${p.id}`, 7200, JSON.stringify(p))
))
}
AWS documents the same procedure as pre-population in the whitepaper Performance at Scale with Amazon ElastiCache. For reference data that does not depend on traffic, the prefetch cache guide covers the CDC-fed version.
What stays out of the cache
Real-time balances, promo prices computed per request, and simple queries the database answers in under 10ms stay uncached. Reading, writing, and invalidating costs more than the original query. The decision criterion: the same read repeats, the origin is slow, and you accept the data going stale within the TTL.
References
- Redis docs: cache-aside, EXPIRE, and prefetch cache
- Amazon ElastiCache User Guide: Caching strategies (AWS)
- Performance at Scale with Amazon ElastiCache (AWS whitepaper)
- RFC 5861, HTTP Cache-Control Extensions for Stale Content (Nottingham, 2010)
- RFC 9111, HTTP Caching (Fielding, Nottingham, and Reschke, 2022)
Enjoyed this content?
I build web products and AI solutions the right way — solid architecture, maintainable code, and real delivery.
Let's talk