How to Add Caching Without Serving Stale Data Forever
Caching is the fastest way to make an application faster and the fastest way to make it wrong. The mechanics are trivial: check the cache, fall back to the source, store the result. Everything hard is invalidation — knowing when the cached copy stopped being true.
The failure mode is asymmetric and worth naming. A cache miss costs latency. A stale cache serves a customer someone else's data, or shows a payment as pending after it settled, and you find out from a support ticket. So the useful question is not "what should I cache" but "what is the cost of this being 60 seconds out of date."
Start from staleness tolerance
For each thing you want to cache, decide how wrong it's allowed to be. That answer determines both the mechanism and the TTL, and it's a product question more than a technical one.
- ›Tolerates minutes of staleness and is expensive to compute (aggregates, dashboards, search facets): cache aggressively with a plain TTL.
- ›Must be current but is read constantly (a user's own profile): cache with explicit invalidation on write, not a TTL.
- ›Personalized per user: cache per user or not at all. A shared cache keyed without the user ID is how one customer sees another's data.
- ›Public and identical for everyone (marketing pages, public listings): push it to the CDN and stop thinking about it.
- ›Changes on every read (counters, live state): don't cache. Fix the query.
The approaches
Cache-aside with Redis or Memcached
// best for: Expensive queries and computed results shared across requests.
- Redis
- PostgreSQL
- Express.js
- Django
- Laravel
- NestJS
The default pattern: application checks Redis, misses, queries the database, writes the result back with a TTL. Simple, explicit, and easy to reason about — you can always answer where a value came from.
The key design is the key. Include everything that varies the result: the entity ID, the version of the serialization format, and the user or tenant if it's scoped. `user:123:dashboard:v2` beats `dashboard:123` because when the shape changes you bump the version and every old entry is orphaned rather than deserialized into the wrong shape.
For invalidation, prefer deleting on write over waiting for a TTL. The write path knows exactly what changed; the TTL is guessing.
// The stampede: TTL expires on a hot key, and every concurrent
// request misses at once and hits the database together.
async function getWithLock(key: string, load: () => Promise<Data>) {
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
// One caller rebuilds; the rest briefly serve the stale value
// or wait, instead of all stampeding the database.
const gotLock = await redis.set(`${key}:lock`, "1", { NX: true, EX: 10 });
if (!gotLock) {
const stale = await redis.get(`${key}:stale`);
if (stale) return JSON.parse(stale);
}
const fresh = await load();
await redis.set(key, JSON.stringify(fresh), { EX: 300 });
await redis.set(`${key}:stale`, JSON.stringify(fresh), { EX: 3600 });
return fresh;
}The gotcha
Cache stampede. A popular key expires and every in-flight request misses simultaneously, hitting the database with the full read volume the cache was absorbing. This reliably happens at peak traffic, which is exactly when the database can least handle it. Use a lock so one caller rebuilds, or jitter TTLs so keys don't expire in lockstep.
HTTP caching with Cache-Control and ETags
// best for: Anything served over HTTP, which is more of your application than you think.
- Express.js
- Next.js
- Django
- Laravel
- Cloudflare
- Vercel
The most underused caching layer, because it's already built into every browser and proxy. Setting `Cache-Control: public, max-age=31536000, immutable` on content-hashed assets eliminates a request entirely — not a faster request, no request at all.
For dynamic content, `ETag` plus `If-None-Match` gives conditional requests: the server still runs the check but returns a 304 with no body, saving bandwidth and serialization.
The distinction that matters most is `public` versus `private`. `private` means only the end user's browser may store it; `public` allows shared caches. Getting this wrong on an authenticated response is how a CDN serves one user's account page to another — the single most damaging caching bug there is.
The gotcha
`s-maxage` controls shared caches and `max-age` controls the browser, and they're frequently set as if they were the same knob. You usually want a long `s-maxage` with a short `max-age`, so the CDN absorbs the load while browsers still revalidate often enough to pick up changes.
CDN and edge caching
// best for: Public pages, static assets, and API responses identical for every caller.
- Cloudflare
- Vercel
- Netlify
- AWS
- Google Cloud
Caching at the edge removes your origin from the path entirely. For a server-rendered marketing page or a public listing, this is the difference between 400ms and 20ms, and it makes traffic spikes a non-event.
Stale-while-revalidate is the feature worth understanding. It serves the cached copy immediately while refreshing in the background, so nobody waits for the rebuild. `Cache-Control: s-maxage=60, stale-while-revalidate=600` means fresh for a minute, then served stale for up to ten more while a background fetch updates it. Users never see a slow page and the origin sees one request per minute.
The operational requirement is purge. When something must change now, you need a way to evict it — ideally by tag, so publishing one post clears the post, the index, and the feed together.
The gotcha
Cookies commonly disable CDN caching entirely, because a `Set-Cookie` on a response marks it uncacheable. An analytics script or a session cookie set on every response silently turns your CDN into a pass-through, and it looks like the cache is configured correctly right up until you check the hit rate.
Common pitfalls
Caching authenticated responses in a shared cache
The most dangerous bug in this guide. Any response varying by user must be `private`, or keyed by user in a shared cache. One `public` on an account endpoint and your CDN serves one customer's data to the next visitor.
No cache key versioning
Change the shape of a cached object and deployed code reads old entries into the new shape. Include a version in the key and bump it whenever the serialization changes — old entries then simply miss and expire.
Caching errors
A transient failure gets cached with a five-minute TTL and the outage outlives its cause by five minutes. Only cache successful responses, and if you cache negative results to prevent lookup storms, give them seconds rather than minutes.
Treating the cache as durable
Redis restarts, evicts under memory pressure, and fails over. Anything that only exists in the cache is data you are choosing to lose. Sessions in Redis are fine if logging everyone out is survivable; a queue in Redis without persistence is not.
Questions people actually ask
What TTL should I use?
Start from how stale the data may be, not from a round number. Most application caches land between 30 seconds and 5 minutes; anything you'd invalidate explicitly on write can have a much longer TTL as a backstop.
Redis or in-memory caching?
In-memory is faster and free but per-instance, so with several servers each has its own copy and invalidation only clears one. Fine for immutable reference data, wrong for anything invalidated. Redis for anything shared.
How do I know if caching is helping?
Measure hit rate and origin latency together. A high hit rate on cheap queries saves nothing; a low hit rate on expensive ones means your keys are too specific or TTLs too short. Only cache what profiling showed was slow.