Module P-13·36 min read

TTL strategy, versioned keys, tag-based purge, and naming your staleness budget out loud so the argument stops being a vibe.

JJS
Written by Jatin Jain Saraf · Senior Software Engineer

Module P-13 — Cache Invalidation

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.

TTLExplicit invalidationVersioned keys
Staleness windowUp to the full TTLMilliseconds to secondsEffectively zero
Writer must know cache keysNoYes, all of themOnly the entity's generation key
AtomicityN/ANo — delete and commit are separateYes, one increment
Cost of a miss stormHigh at expiry, needs jitter (F-12)High right after a purgeHigh right after a bump
Memory costBoundedBoundedOld generations linger as garbage
Fails howSilently serves stale for the windowSilently misses a key nobody rememberedMemory pressure evicts unrelated hot keys
Good forData with a tolerable staleness budgetA small, stable, well-known key setWide 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:

DataTolerable stalenessMechanismWhat breaks if exceededOwner
Authorisation, session revocation, suspension≤ 5 sExplicit invalidation + 5 s TTL floor + pub/subSuspended account keeps acting; security incident (A-13)Security
The amount actually charged0 — never cachedRe-read on the primary inside the transactionWrong amount charged; chargeback (P-24)Payments
Displayed price on a listing60 sGeneration key + CDN surrogate keyPrice-match complaint, order at the wrong priceCommerce
Stock level shown as a badge30 s, displayed as approximateTTL, no invalidationOversell, cancellationCommerce
Product title, description, images5 minTag purge on publishVisibly wrong copyCatalogue
CMS / marketing pages0 from the editor's point of viewExplicit purge on publish + preview bypassEditor loses trust in the CMS entirelyContent
Search rankings and facets15 minIndex rebuild cadence (P-20)New item feels invisibleSearch
Follower counts, view counts5 minTTL onlyNothing. This is the point of the tableGrowth
Feature flags, kill switches≤ 10 sPub/sub + short TTLCannot turn off a bad feature quicklyPlatform
Analytics dashboards1 hMaterialised rollups (P-22)A wrong decision made slowlyData

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

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.