What this module covers: The three ways a cache entry stops being trusted — TTL expiry, explicit invalidation on write, and versioned keys — and the specific cost each one imposes. Then tag-based invalidation and how it maps onto CDN surrogate keys and framework revalidateTag; the staleness budget as a written product artefact rather than a config value; the read/write interleaving that caches a stale value indefinitely and why write-through plus versioning is immune to it; the fan-out problem where one product edit touches 50,000 keys; and multi-layer coherence, where Redis is correct and forty in-process caches are not.
Stale Data Is a Correctness Bug Wearing a Performance Costume
Module F-12 established the mechanics of caching: hierarchy, hit ratio, working set, TTL and jitter. This module is about the part that generates pages.
Nobody gets paged because a cache is 30ms slower than expected. They get paged because:
A customer's card was declined for insufficient funds, and the balance the check used was 90 seconds old.
An account was suspended for abuse and kept posting for four minutes, because the permission check reads from a cache with a five-minute TTL.
A merchant corrected a price from ₹4,999 to ₹499, then watched orders arrive at the old price for the rest of the afternoon, because the listing page cache was never told.
An editor published a correction to a legal disclaimer, saw it live on their own request, and got a screenshot from a customer two hours later showing the old text — one CDN POP had a copy nobody purged.
Each of these is the same shape. A value was cached, the truth changed, and the cache didn't find out — or found out and got it wrong. Caching converts a read-latency problem into a data-freshness problem, and freshness failures do not look like performance failures. They look like bugs, billing disputes, and security incidents.
The uncomfortable framing: every cache is a replica, and every replica has a consistency model. Once you cache, you have built a distributed system with at least two copies of the truth and no consensus protocol between them (Module P-11 gives the vocabulary; Module A-1 gives the machinery you are deliberately not using). Invalidation is the entire design of that consistency model, and most systems ship it by accident — a TTL somebody typed in 2019 and nobody has revisited.
Three Ways to Invalidate, and What Each One Costs You
There are exactly three primitives. Everything else is a composition of them.
1. TTL expiry. The entry carries a lifetime; when it expires the next read misses and refetches. No writer needs to know the cache exists. Staleness is bounded by the TTL and unbounded in the other direction — a value can be wrong for the full TTL and you will not know which requests got the wrong answer.
2. Explicit invalidation on write. The code path that changes the truth also deletes or overwrites the cache entry. Freshness is near-immediate. The cost is coupling: every writer must know every key that derives from the data it just changed. That knowledge lives in nobody's head after the third quarter. A background job, a data migration, an admin console, and a second service in another language all write to the same table, and only two of them remember the cache.
3. Versioned (generation) keys. The key embeds a version or generation counter for the underlying entity. You never delete anything; you bump the counter, and every key built from the old counter becomes unreachable. Invalidation is atomic — a single counter increment — and needs no purge, no key enumeration, and no cooperation from the cache. The cost is garbage: the old entries still occupy memory until they age out or get evicted, and that eviction pressure falls on other, still-useful entries.
TTL
Explicit invalidation
Versioned keys
Staleness window
Up to the full TTL
Milliseconds to seconds
Effectively zero
Writer must know cache keys
No
Yes, all of them
Only the entity's generation key
Atomicity
N/A
No — delete and commit are separate
Yes, one increment
Cost of a miss storm
High at expiry, needs jitter (F-12)
High right after a purge
High right after a bump
Memory cost
Bounded
Bounded
Old generations linger as garbage
Fails how
Silently serves stale for the window
Silently misses a key nobody remembered
Memory pressure evicts unrelated hot keys
Good for
Data with a tolerable staleness budget
A small, stable, well-known key set
Wide fan-out, derived and composed data
Analogy: the price labels on supermarket shelves. The till system is the database; the printed labels are the cache. TTL is reprinting every label every Monday morning — cheap, no coordination, and wrong for up to a week. Explicit invalidation is requiring whoever changes a price in the office to walk the floor and replace the shelf label, the end-cap poster, and the weekend flyer — correct, until the day they forget the flyer. Versioned keys are printing today's date on every label and telling shoppers to read only today's: you never remove anything, you just stack the new label on top, and the old ones rot underneath taking up space in the drawer.
In practice, production systems use all three at once, layered: a generation key for the entity, tags for the pages that compose it, and a TTL underneath everything as the safety net for whatever the other two missed. A TTL is not redundant with explicit invalidation — it is the bound on how wrong you can be when explicit invalidation fails. An invalidation scheme with no TTL floor has an unbounded worst case, and the rest of this module is mostly examples of that worst case.
Write the Staleness Budget Down or Someone Else Decides It
Module F-12 made the point in the abstract: unnamed staleness is an unowned product decision. Here it is concretely. A staleness budget is a table you keep in the repository, next to the schema, reviewed like an API contract:
Three things happen when you write this table down.
First, the rows with a zero budget stop being cached at all. The amount you charge, the balance you check before a debit, the "is this token revoked" check at the boundary of a money movement — these are read-modify-write decisions, and Module P-5's replication lag makes even the read replica unsafe for them. A cache is a much worse replica than a replica. The design rule is blunt: never cache the value you are about to make an irreversible decision on. Cache the render of it; read the authority for the decision.
Second, the rows with a generous budget stop getting invalidation machinery they never needed. A follower count with a five-minute budget needs one line of TTL configuration, not a purge path, not a tag, and not an entry in a writer's mental checklist. Half of the accidental complexity in invalidation code exists because nobody was willing to say out loud that a number could be five minutes old.
Third, the CMS row exposes something teams get wrong repeatedly: the editor's staleness budget is zero and the reader's is five minutes, for the same content. Editors judge the entire platform by whether their change appears when they refresh. Serving editors from a cache-bypassing preview path while readers get a five-minute TTL resolves it. Trying to make the shared path fast and instantly fresh for everyone is how you end up purging the whole CDN every time somebody fixes a typo.
Why this matters in production: without this table, the staleness question gets answered ad hoc by whichever engineer touched the code last, at 2am, under pressure, and the answer is usually "make the TTL shorter." Shortening TTLs is the one lever that degrades both freshness accountability and hit ratio, and near the top of the hierarchy Module F-12 showed that hit ratio has increasing marginal value — so the reflex fix costs the most where you can least afford it.
Tags Turn One Write Into One Purge
Enumerating keys does not survive contact with real page composition. A single product's data appears in its detail page, three category listings, two search result pages, a recommendation carousel on nine other products, the sitemap, the RSS feed, and a locale/currency variant of each. Asking the write path to name all of them is asking it to reimplement the rendering layer.
Tags invert the relationship. When a cache entry is created, the reader — which knows exactly what data it touched — declares its dependencies. When a write happens, the writer names only the entity it changed. The cache does the join.
ts
The same idea has three names at three layers, and they are the same mechanism:
Layer
Mechanism
Purge granularity
CDN
Surrogate-Key response header, purge-by-key API (Module F-15)
One tag purges every edge object carrying it, in every POP
Framework
revalidateTag('product:42') against the data cache
Every cached render that declared the tag
Application cache
Tag → key set maintained in Redis (a Set per tag)
Every key registered under the tag
The CDN case is the one worth internalising, because it is the only way to purge edge content correctly at scale. Module F-15 covered purge strategies; surrogate keys are the one that composes. The origin emits Surrogate-Key: product-42 category-electronics alongside Cache-Control: s-maxage=300, stale-while-revalidate=600, and a single purge call for product-42 reaches every POP holding any object tagged with it — without you ever knowing which URLs those were.
The costs, because tags are not free:
The tag index is itself state that can be wrong. A Redis Set per tag grows unboundedly unless entries are cleaned up on eviction, and Redis does not tell you which tags an evicted key belonged to. You end up with tag sets full of dead key names — harmless, but memory you are paying for, and a purge that iterates 200,000 stale members to delete 300 live ones.
Over-tagging collapses to purge-everything. Tagging a page with product:* for all 400 products on it means any one of 400 edits purges the page. Correct, and your hit ratio for that page is now a function of your total edit rate rather than your read rate.
A purge is a coordinated miss. Purging one tag that 50,000 keys share converts 50,000 warm reads into 50,000 origin requests within one TTL window. stale-while-revalidate and origin shield request collapsing (F-15) are what keep that from becoming an origin outage — the mechanism that makes purge-by-tag survivable is not the purge, it is what happens on the miss.
The Interleaving That Caches a Stale Value Forever
This is the bug that makes cache invalidation famous, and it is worth being able to draw from memory.
Cache-aside, the default pattern from Module P-12: readers check the cache, miss, read the database, and populate the cache. Writers update the database and delete the cache entry. Both halves look obviously correct. Here is the interleaving:
text
The reader was descheduled between step 2 and step 5 — a GC pause, a slow serialisation, a network hiccup, a noisy neighbour, any of Module F-4's tail-latency sources. The writer's delete lands on an empty key and does nothing. Then the reader writes v1 into the cache after the invalidation that was supposed to remove it.
The cache now holds v1, the database holds v2, and no future event will fix it. The next write will, whenever that is. If the key has no TTL, "whenever that is" can be never. This is not a rare interleaving — at a few thousand reads per second against a hot key, the window is hit routinely, and it produces exactly the bug report that destroys trust in a system: one customer sees the wrong price, permanently, and nobody can reproduce it.
Writer ordering does not save you. Both orderings have a hole:
Writer ordering
The hole
Delete cache, then write DB
Between the delete and the commit, a reader misses, reads the pre-commit value, and caches it. Stale immediately after commit, indefinitely.
Write DB, then delete cache
Correct against readers that started after the commit — but the reader interleaving above is unaffected. Also, if the process dies between commit and delete, the stale entry survives.
Delete, write, delete again after a delay
Narrows both windows. Does not close them, and now you have a scheduled task that can fail.
Write-then-delete is the better default, and it is not a fix. Neither is wrapping the cache delete in the database transaction — that is Module F-14's dual-write problem with a fresh coat of paint: two systems, one of which can fail after the other succeeded, and no atomic commit across them.
What actually closes it is versioning plus write-through. Two changes:
The data key embeds a version that advances with the write. The stale value the racing reader writes goes into product:42:v1, which is a key nobody will ever ask for again. The reader poisons a tombstone. Readers resolve the current version first, then fetch the versioned key.
The writer populates the cache with the value it just committed (write-through), rather than deleting and hoping a reader repopulates correctly. The writer holds the authoritative value and knows its version, so it can write it in commit order.
Because writes can still arrive at the cache out of order — two concurrent writers, or a retry from Module A-6 delivering an old value late — the cache write must be a compare-and-set on the version, which Redis can do atomically in a script:
lua
The version can be the row's xmin, an updated_at in microseconds, a monotonic per-entity counter, or the transaction's commit LSN if you are driving invalidation from change data capture (Module P-23) — which is the most robust source available, because it is derived from the write-ahead log rather than from application code remembering to publish.
Why this matters in production: the version check is the difference between a cache that converges on the truth and one that can be permanently wrong in a way no monitoring will catch. A stale-forever entry produces no error, no latency spike, and no log line. The only signal is a customer, eventually, if you are lucky.
One Product Edit, Fifty Thousand Keys
Now the scale problem. A merchant fixes a typo in a product title. That product appears in:
search results for ~400 queries that match the title
recommendation blocks on ~2,000 other product pages
3 curated collection pages, a sitemap shard, and a feed
Multiply the locale and currency dimensions through — Module F-12's cache key explosion, arriving as an invalidation bill — and you are somewhere north of 50,000 keys for a one-character edit.
Enumeration is not an option, for three separate reasons:
You cannot compute the list. Producing it means knowing every render path, forever, including the ones added last week by a team that never heard of your cache.
Scanning is worse.SCAN with a match pattern walks the entire keyspace — millions of keys to find 50,000 — and on a single-threaded Redis instance that is throughput you are taking from the read path (KEYS is worse: it blocks). This is the classic "we'll just pattern-match the keys" idea, and it is a real technique that is simply wrong at this size.
50,000 deletes is a write burst, and the 50,000 coordinated misses that follow hit the origin inside one window.
So the fan-out cost is itself the argument for the other two strategies:
Approach
Cost of one product edit
Cost it moves elsewhere
Enumerate and delete
50,000 DELs + maintaining a reverse dependency index
The index is a cache that can be stale, and it must be updated on every render
SCAN/pattern match
Full keyspace walk per edit
Steals read throughput; unusable above a few hundred thousand keys
Tag purge
One purge call per tag
Tag→key set maintenance; a coordinated miss storm
Generation bump
One INCR
Garbage: 50,000 orphaned entries occupying memory until eviction
Generation bumping is the cheapest write and the most expensive memory profile, and the memory cost is worth stating precisely: the orphaned entries do not politely wait to be reclaimed — they compete. Under allkeys-lru they push other keys out, so a bulk price update across 10,000 products can evict a large slice of your working set and drop your hit ratio for as long as it takes to re-warm. This is Module F-12's "LRU dies to sequential floods" arriving from the invalidation side. Mitigations, each with a cost: shorter TTLs on generation-keyed entries so garbage self-clears (lower hit ratio); a separate Redis database or instance for generation-keyed data so its garbage cannot evict anything else (more infrastructure, worse memory utilisation); or volatile-lru with explicit TTLs everywhere (a key without a TTL becomes un-evictable, which is its own failure mode).
The read path costs something too. Resolving a generation before fetching data is two round trips instead of one — roughly 0.5ms of same-datacentre RTT added to every read, which is fine until you are composing a page from 40 cached fragments and have added 20ms of pure round trips. The standard fix is to fetch the generations you need in one pipelined MGET, and to hold generations in a very short in-process cache, which brings us to the last and least-appreciated problem.
ts
That last comment is the design rule for every invalidation scheme: decide what happens when the invalidation infrastructure fails, and make the failure mode "miss," never "serve stale." A generation counter that was evicted, a pub/sub message that was dropped, a tag index that was truncated — all of them should cost you a cache miss and a slower page, not a wrong answer.
You Invalidated Redis; Forty Processes Still Disagree
The scenario that catches out most teams: you purge the CDN, you bump the generation in Redis, you verify the new value is there, and users still report the old one. Because between the CDN and Redis sits a tier of 40 application instances, each holding an in-process Map of hot values, and nothing you did touched any of them.
In-process caching is not a mistake — it is the top of Module F-12's hierarchy, where a hit costs ~100ns instead of ~0.5ms, and where hit ratio has the highest marginal value. The mistake is having a cache with no invalidation story in a system that has one for every other layer.
Three options, and you will normally use the first two together:
Mechanism
Staleness
Cost
Short in-process TTL (1–5 s)
Bounded by the TTL, per instance
40 instances expire independently → 40× refetch against Redis at expiry unless jittered (F-12). Divergence between instances during the window.
Pub/sub invalidation channel
Sub-millisecond in the happy path
Fire-and-forget delivery. An instance that was disconnected, GC-paused, or mid-deploy misses the message and stays stale forever. Fan-out grows with instance count.
No in-process cache
None
You gave up the fastest layer, and every request now pays a network round trip and adds load to Redis.
The production pattern is both, with clear roles: pub/sub is the fast path; TTL is the correctness bound. Redis pub/sub is at-most-once with no persistence and no acknowledgement — an instance that reconnects has no way to learn what it missed. So the TTL is not a belt-and-braces nicety, it is the only thing that makes the worst case finite.
ts
Two second-order effects worth knowing about.
Instances disagreeing breaks read-your-writes. A user edits their profile, the write commits, the L7 load balancer sends their next request to a different instance (Module F-11 — no stickiness, correctly), and that instance's in-process copy is two seconds old. The user sees their edit vanish, refreshes, sees it again, and files a bug titled "saves are unreliable." This is a monotonic-reads violation (Module P-11) with a cache instead of a replica underneath it. The fix is not stickiness, which Module F-9 priced at four separate costs. It is a short-lived per-user bypass: after a user's own write, mark them for the next few seconds and serve them from the authority. Cost: your most engaged users get your slowest path, which is exactly backwards, and worth accepting anyway.
Deploys clear all forty caches at once. A rolling restart is a synchronised cold-start of the entire top layer, so every deploy is a small load test of the layer underneath (Module O-4). Whether that matters is answerable — it is the difference between your Redis tier absorbing the miss rate and your database absorbing it — and it is the reason a deploy sometimes correlates with a latency spike nobody can attribute to the code that shipped.
What Invalidation Cannot Fix
Some reads must not be served from a cache at all, and the invalidation design should say so explicitly rather than leaving it to be discovered:
Read-modify-write decisions. Balance checks before a debit, quota checks before an allocation, stock decrements. These need the primary and a lock or a conditional update (Modules P-1, A-2). Caching them converts a stale read into money.
Anything you would show in a dispute. If a value could end up in a chargeback, an audit, or a legal request, the render can be cached; the authoritative fetch cannot.
Negative caches on things that are about to exist. Caching a 404 for a product page during a launch, with a five-minute TTL, means the product is invisible for five minutes after it goes live. Negative entries need their own, much shorter budget — and they need to appear in the staleness table like everything else.
Permission and revocation checks, unless the budget explicitly permits a few seconds and you have both a pub/sub path and a TTL floor. Module A-13 treats this as a security property, not a performance one.
Where This Shows Up in Your Stack
PostgreSQL: the row's xmin or a microsecond updated_at is a serviceable version for compare-and-set cache writes. For invalidation you can actually trust, drive it from the write-ahead log via logical decoding (Module P-23) rather than from application code — the WAL cannot forget to publish, and it gives you commit ordering for free. LISTEN/NOTIFY is tempting for invalidation fan-out and is at-most-once, not durable, with payloads capped at 8 KB; it needs the same TTL floor as Redis pub/sub.
Redis:INCR on a generation key is the cheapest invalidation available. Use Lua for version-compared writes so reordered writes cannot win. Never KEYS in production and be wary of SCAN-based invalidation at scale; prefer a Set per tag, and remember the Set outlives the keys it names. Client-side caching with tracking (RESP3 invalidation push) implements the pub/sub-plus-TTL pattern for you, with the same delivery caveats.
Node.js: an in-process Map or LRU is the fastest cache you have and the easiest to forget. Give every in-process entry a TTL measured in seconds, jitter it, subscribe to an invalidation channel, and clear the whole cache on reconnect. Also: await-ing an invalidation after the response has been sent does not reliably run on serverless (Module F-10's "no work after the response returns").
CDN / framework layer:Surrogate-Key plus purge-by-key is the only edge invalidation that composes (Module F-15). revalidateTag is the same primitive inside the framework's data cache. Pair both with stale-while-revalidate so a purge produces a fast stale response and a background refetch instead of a coordinated miss storm at the origin.
Summary
Caching converts a latency problem into a freshness problem — and freshness failures surface as billing disputes and security incidents, not as slow requests.
Every cache is a replica with a consistency model. Invalidation is that model, and most systems ship it by accident.
Three primitives, three costs: TTL is free of coordination and bounds staleness only loosely; explicit invalidation is fresh but requires every writer to know every derived key; versioned keys are atomic and purge-free at the cost of garbage that competes for memory with your working set.
A TTL underneath everything is not redundant — it is the bound on how wrong you can be when the clever mechanism fails. Without it, the worst case is "stale forever."
Write the staleness budget down as a table of data type versus tolerable staleness, with an owner. Rows with a zero budget stop being cached; rows with a generous budget stop getting machinery they never needed. Unnamed staleness is an unowned product decision.
Tags invert the dependency direction: readers declare what they touched, writers name one entity, the cache does the join. The same primitive is CDN surrogate keys, framework revalidateTag, and a Redis Set per tag — costing tag-index maintenance and a coordinated miss storm on purge.
The classic race caches a stale value indefinitely: a reader misses, reads v1, is descheduled; the writer commits v2 and deletes nothing; the reader then writes v1. No writer ordering closes it. Write-through plus a versioned key does — the racing reader poisons an orphaned key — with a version compare-and-set to reject reordered writes.
Fan-out is the argument against enumeration: one product edit can touch 50,000 keys, so enumerating is impossible, SCAN steals read throughput, and only tags or a generation bump scale. The generation bump's bill is memory pressure that evicts unrelated hot keys.
Multi-layer coherence is where teams get caught: Redis correct, 40 in-process caches wrong. Use pub/sub as the fast path and a short jittered TTL as the correctness bound, because pub/sub is at-most-once and a disconnected instance stays stale forever otherwise.
When invalidation infrastructure fails, fail to a miss, never to a stale hit. A missing generation counter means generation 0, not "serve what you have."
The next module, Message Queues, takes the invalidation fan-out you just priced and gives it somewhere to go: purging 50,000 edge objects and notifying 40 instances are asynchronous side effects of a write, and doing them inline on the request path is how a typo correction becomes a latency incident. It also introduces the delivery guarantees that Redis pub/sub conspicuously lacks — which is the real reason the TTL floor exists in this module and not in that one.
Knowledge Check
A hot product page occasionally shows a stale price to a small number of users, permanently — a targeted cache flush fixes it, and it recurs days later with no pattern. The system uses cache-aside: readers populate on miss, writers UPDATE then DEL the key, and keys have no TTL. What is happening, and what fixes it?
A merchant corrects one product title. That product appears across roughly 50,000 cached keys once locale, currency, pagination, sort order, and recommendation blocks are multiplied through. The write path must invalidate all of them. What does the module recommend, and what does it cost?
You purge the CDN and bump the generation key in Redis after a content fix, and verify the new value in Redis. Users continue to report the old content for several minutes. Forty application instances each hold an in-process LRU of hot values. What is the correct design, and what does it cost?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// The read path declares dependencies as it renders. It knows them; the writer doesn't.const page =await cache.fetch( cacheKey,// e.g. "listing:electronics:page-2:en-IN"()=>renderListing(category, page, locale),{ tags:['category:electronics',...productIds.map((id)=>`product:${id}`)]},);// The write path names one entity and stays ignorant of the 50,000 keys it affects.await db.transaction(async(tx)=>{await tx.update(products).set({ priceCents }).where(eq(products.id, id));// Enqueued inside the transaction, executed after commit — never before.await tx.enqueueAfterCommit({ type:'invalidate', tags:[`product:${id}`]});});
Reader Writer
------ ------
1. GET product:42 → MISS
2. SELECT ... id = 42 → v1
3. UPDATE ... → v2, COMMIT
4. DEL product:42 (deletes nothing)
5. SET product:42 = v1
-- SET only if the incoming version is newer than what's stored. Rejects late/reordered writes.local current = redis.call('HGET', KEYS[1],'version')if current andtonumber(current)>=tonumber(ARGV[1])thenreturn0-- a newer (or equal) version is already cached; drop this writeendredis.call('HSET', KEYS[1],'version', ARGV[1],'data', ARGV[2])redis.call('EXPIRE', KEYS[1], ARGV[3])-- TTL floor: the safety net, not the mechanismreturn1
// One pipelined round trip for all generations, then one for all data keys.const gens =await redis.mget(ids.map((id)=>`gen:product:${id}`));const keys = ids.map((id, i)=>`product:${id}:v${gens[i]??'0'}:${locale}`);const hits =await redis.mget(keys);// A missing generation is treated as generation 0, not as an error:// a lost generation counter must degrade to a miss, never to a stale hit.
const local =newLRUCache<string, Entry>({ max:10_000, ttl:3_000});// ← the correctness boundsub.subscribe('cache:invalidate');sub.on('message',(_ch, raw)=>{const{ key, version }=JSON.parse(raw)as{ key:string; version:number};const held = local.peek(key);// Version-compare rather than blind delete: an out-of-order message must not// evict a value that is newer than the message describes.if(!held || held.version <= version) local.delete(key);});// On reconnect, assume nothing: messages published while disconnected are simply gone.sub.on('ready',()=> local.clear());