Lesson 13 of 41
Cache Invalidation Strategies
The hard part of caching isn't storing a value — it's knowing when to throw it away. Tag-based, time-based, path-based, mutation-triggered: shapes, trade-offs, and when each fits.
Caching is easy. Pick a store, write a value under a key, return the cached value on the next read. The hard part — the famously-hard part — is knowing when to throw a cached value away. Get it wrong by invalidating too eagerly and you've paid the cost of the cache for nothing; get it wrong by invalidating too lazily and users see stale data, sometimes in confusing or embarrassing ways.
This lesson is about the design space of invalidation. It covers the four mechanisms that keep coming up — tag-based, time-based, path-based, mutation-triggered — and the honest rules for when each is the right tool.
The invalidation design space
Every cache has at least one of these mechanisms:
- Step 1
Time-based (TTL / SWR)
The cached value has an expiry. On a read past it, re-fetch. Defensive; cheap; tolerates missed invalidations; high-staleness risk inside the window.
- Step 2
Mutation-triggered
A write explicitly invalidates the entries it affects. Precise; requires a dependency graph between writes and keys; zero staleness when correct.
- Step 3
Path / key based
Invalidate specific URL(s) or specific cache keys. Simple to reason about; granular; needs you to know which paths depend on which data.
- Step 4
Tag-based
Cached entries carry tags; a tag invalidation nukes every entry carrying that tag. Expressive; good for 'everything that touches user 42 is stale.'
Most real systems use more than one at the same time, for different reasons. The useful reasoning move isn't picking one; it's knowing when to lean on which.
Time-based: the defensive default
Every cache should have some TTL, even when you also have precise invalidation. The reason is operational, not correctness: your precise invalidation will sometimes miss. Some write path will forget to call it; some message will fail to deliver; some bug will ship. When that happens, a TTL is the floor on how stale a value gets.
// Next.js — a cached fetch with a 60-second TTL
const data = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});The stale-while-revalidate variant is the one worth
internalising for user-facing performance: serve the cached value
immediately (fast), kick off a re-fetch in the background, and
update the cache with the result for the next request. Browsers do
this with the stale-while-revalidate HTTP cache directive; CDNs
implement it on top of HTTP; data libraries implement it in memory.
TTL is defensive but not precise. If a product's price just changed and the cache has 58 seconds left on its TTL, 58 seconds of users see the old price. If that's unacceptable, you need one of the targeted mechanisms.
Mutation-triggered: the precise answer
When a mutation runs — an admin edits a price, a user submits a comment, a job processes a payment — the code paths that did the mutation know exactly what changed. That's the signal to invalidate.
// Server Action
"use server";
export async function updateProduct(id: string, data: ProductUpdate) {
await db.product.update({ where: { id }, data });
revalidateTag(`product:${id}`);
revalidatePath(`/products/${id}`);
}Two kinds of invalidation call there:
revalidateTag('product:123')— invalidate every cache entry tagged withproduct:123. The product's own page, a "related products" list that tagged it, a search index entry. Tag-based.revalidatePath('/products/123')— invalidate the cache entry for that specific route. Path-based.
The key design decision is which cache keys and tags to use.
Keys that are too coarse (e.g. 'products') cause a flood of
invalidations on every edit; keys that are too fine
('product:123:price' vs 'product:123:description') are more
work to invalidate correctly because you have to remember them
all. The sweet spot is usually at the entity granularity — one
tag per product, per user, per order — plus a small number of
coarser tags for things like "the homepage" or "the search index"
that genuinely need to invalidate together.
Tag-based: expressing dependencies
Tags are the mechanism that makes "invalidate everything that depends on X" expressible as one call, regardless of where the dependency was recorded. A cache entry can carry multiple tags:
const user = await fetch(`/api/users/${id}`, {
next: { tags: [`user:${id}`, "users"] },
});
const posts = await fetch(`/api/users/${id}/posts`, {
next: { tags: [`user:${id}`, `posts:user:${id}`] },
});When user 42 updates their profile, a single
revalidateTag('user:42') invalidates both of those entries,
plus anywhere else in the codebase that tagged a fetch with
user:42. The tags are the dependency graph, written
inline next to each cache insertion.
Tag-based invalidation is more expressive than path-based (path-based only knows about URLs; tags know about concepts) and more precise than TTL (TTL doesn't know what changed). The cost is discipline: tags are only useful if every caller tags consistently. One route that forgot to tag its user fetch is a hole in the dependency graph.
The multi-layer problem
The piece most often missed in a design interview: there isn't just one cache. A production system typically has, from bottom to top:
- Browser HTTP cache —
Cache-Controlheaders, driven by response headers. - Service worker cache — governed by the worker's fetch handler (covered in the service-workers lesson).
- CDN edge cache — keyed by URL + vary headers, invalidated via the CDN's API.
- Framework data cache — Next.js's
fetchcache, Remix's loaders, invalidated byrevalidateTag/revalidatePath. - In-memory client cache — React Query, SWR, Apollo, invalidated by their query-key API.
- Application cache — Redis / Memcached / in-process LRU, invalidated by application code.
Each layer is invalidated by a different mechanism. A single mutation that needs to be user-visible everywhere has to touch multiple layers. The design skill is asking, for each layer: "who invalidates it, and when?"
The admin who made the change usually sees the update almost
immediately (their own revalidateTag call clears the framework
cache for them). Other users see it when:
- Their framework data cache's TTL expires (or you push an invalidation to it).
- The CDN edge cache is purged (via the CDN's API call from the mutation handler).
- Their browser HTTP cache's
max-ageexpires, or the response URL changes (common trick: content-hashed asset URLs so the browser fetches a different URL for the new content).
The honest answer to "how long until everyone sees the change?" is almost always "it depends on the TTL of the slowest-propagating layer." Which is why planning the layers matters.
Three patterns worth copying
Three invalidation shapes come up often enough to be worth naming:
- Write-through. The mutation updates the database and updates (or invalidates) the cache in the same transaction. No staleness window on the server; works best when the cache is simple (single key per entity).
- Event-driven. The mutation publishes an event; a listener invalidates the caches that care. Decouples the mutation from the cache; useful when the cache lives in a different service (CDN, search index, another application).
- Timestamped read with "as of". Store a
last_modifiedon the entity. Reads include that timestamp. Caches are keyed by(entity_id, last_modified). Updates change the timestamp, which naturally means the old cache key is dead. No explicit invalidation needed; the old key is just unreachable.
The third one is quietly elegant and worth knowing — it's effectively how content-hashed asset URLs achieve "the browser always gets the latest JS bundle" without the browser having to know how to invalidate anything.
When "just use TTL" is the right call
Not every cache needs sophisticated invalidation. A time-based cache with a sensible TTL is enough when:
- The data is naturally time-bounded (today's weather, the stock price in the last minute).
- The system can tolerate staleness for the TTL window. (A dashboard showing "orders in the last hour" doesn't need to be perfectly precise.)
- Precise invalidation would require tracking too many dependencies to be worth it.
The failure mode of "just use TTL" is visible: stale data for up to the TTL window. The failure mode of over-engineered precise invalidation is invisible: missed invalidations that nobody notices until a user does. "Just use TTL" is the honest default for anything whose freshness isn't business-critical.
What to remember
- Cache invalidation is hard because it requires knowing the causal graph between writes and cached reads. The storing side is straightforward; the knowing-when-it's-stale side isn't.
- Four mechanisms: time-based (TTL / SWR), mutation-triggered, path-based, tag-based. Most real systems use more than one, for different reasons.
- Time-based is the defensive default. Even with precise invalidation, a TTL caps how stale a value can get when precise invalidation misses.
- Tag-based invalidation lets you express "everything that depends on this concept is stale" as one call. The cost is tag-discipline across every cache insertion.
- A real system has multiple cache layers (browser, service worker, CDN, framework, client-side, application). Each is invalidated differently. The admin and all other users often see changes at different latencies.
- Three patterns worth copying: write-through (update + invalidate in one transaction), event-driven (publish events, listeners invalidate), timestamped-read (cache key includes a
last_modified, so old keys naturally die). - "Just use TTL" is the right call for time-bounded data and anything whose freshness isn't business-critical. Over-engineered invalidation that misses writes is worse than a known TTL.
Check yourself
4 questions · pass 3/4 to unlock State Machines for Complex UI
1.Why is 'cache invalidation' famously called one of the two hard problems in computer science?
2.A product page's price is cached. The admin changes the price. What's the difference between *time-based* invalidation and *mutation-triggered* invalidation?
3.You're caching a list of posts tagged
['posts', 'user:42']. User 42 updates their profile. What does *tag-based* invalidation let you do, and why is it a useful shape?4.An end-to-end system has a browser HTTP cache, a React Query / SWR cache, a framework data cache (Next.js
fetchcache), a CDN edge cache, and an application-level Redis cache. The admin edits a product. Which caches need to be invalidated for the admin to see the change, and which for all other users?
4 left to answer
Discussion
Sign in to postNo comments yet. Be the first to say something.