Cache Invalidation and Freshness Control

LESSON

Caching, Workers, and Performance

004 30 min intermediate

Cache Invalidation and Freshness Control

By the end of this lesson, you will be able to...

  • Define an acceptable-staleness promise for a read path before selecting a cache mechanism.

  • Compare expiry, explicit invalidation, versioned copies, and validation using a concrete update timeline.

  • Review a cache change for its affected copies, refill pressure, and authoritative decision boundary.

Idea in one sentence: Invalidation is not mainly deleting a key; it is controlling how and when readers stop trusting an older copy after the source changes.

Core Insight

At 10:00, an editor changes the price of course 204 from €49 to €59.

The platform has several copies of information about that course: a shared Redis entry used by the API, a public catalogue response at an edge cache, and browsers that recently viewed the page. The editor expects the catalogue to change. A learner at checkout expects the charge to be correct. Those expectations are not equally strict.

If the catalogue says €49 for another 30 seconds, the product team may accept that. If checkout accepts €49 after the price service says €59, the system has made the wrong decision.

This is the central idea:

one source changes
-> several old copies may still exist
-> each reader needs a rule for how long it may trust its copy

Lesson 003 chose where a response may be reused. This lesson chooses what happens when its source changes. The task is not to eliminate every old copy instantly. The task is to make the age, scope, and consequences of an old copy explicit.

The Promise We Need to Keep

Start with a promise, not a timer.

For course 204, write three separate promises:

Read or action Product promise What stale data can do
Public catalogue card A price or description can be up to 60 seconds old A learner may briefly see an older display
Course-details API used for browsing A change should appear shortly without making every read reach the database Readers may receive a bounded old representation
Checkout confirmation The charged price and eligibility must be current at the decision An old value could charge incorrectly or admit an invalid enrollment

The first two are freshness contracts. They say how long a reader may believe a copy without checking again. The last is an authority contract: the action must ask the system that owns the decision.

Plain meaning:

A freshness contract states how confidently and how long a reader may use an old answer.

In this scenario:

The public catalogue may show €49 briefly after the update, but checkout must ask the price authority for the current value before it commits.

Technical name:

The allowed age is the response's freshness budget. A cache policy, invalidation event, or validation rule implements that budget; none of them creates it.

The first design question is therefore not “what TTL should we use?” It is:

How old may this answer be on this path before the product promise is broken?

The Naive Design: Delete One Key After Every Write

The obvious implementation looks small:

def change_course_price(database, cache, course_id, new_price):
    database.update_price(course_id, new_price)
    cache.delete(f"course:{course_id}:details")

It improves one thing. The next API request cannot reuse that particular Redis key.

But the visible course price may also exist in a catalogue response, an edge entry, a browser entry, a search card, or a precomputed summary. Deleting one object key does not tell those copies what changed. Worse, a failure between the database write and cache.delete leaves the old entry behind. Reversing the order has a different problem: readers may refill the cache with the old database value while the update has not committed yet.

The naive design fails because it treats invalidation as a single storage operation. The real unit is the read surface: every representation and path through which users can observe the changed fact.

Before choosing a mechanism, list:

That list is more valuable than a clever key-deletion command.

Four Ways Copies Learn About Change

Different mechanisms answer the same question: how does a copy stop being trusted? They make different promises and fail differently.

Mechanism How the copy learns Good fit Main cost or limit
Expiry (TTL) Time runs out Bounded staleness is acceptable and simplicity matters It does not react at the moment of a write
Explicit invalidation The write path names affected copies A change needs to become visible sooner The system must find every affected representation and survive delivery failure
Versioned copy A new immutable name or generation is published Assets or generated views can move to a new edition safely Old references remain until callers move; naming and cleanup matter
Validation A cache asks whether its existing copy still matches Checking is cheaper than transferring or rebuilding every response It still depends on a reachable authority and a correct validator

Do not read this table as a menu where one row wins. A course page can use several mechanisms: a short expiry for a public card, an explicit update for a shared object copy, versioned assets, and validation at a delivery boundary.

The mechanism follows the promise. A 60-second TTL is a reasonable implementation only when “up to 60 seconds old” is a statement the product can actually keep.

A Worked Update Timeline

Use one deliberately small policy:

Authoritative price service: owns the checkout decision
Redis course details: may be at most 60 seconds old for browsing
Public catalogue response: may be at most 60 seconds old for browsing
Checkout: reads the current price from the authority before confirmation

At 10:00, the editor changes the price to €59. Trace the event.

Time Event Redis course copy Public catalogue copy Checkout decision
09:59 Existing state €49, 20 seconds left €49, 15 seconds left Authority says €49
10:00 Editor commits €59 Old copy may still exist Old copy may still exist Authority now says €59
10:00:05 Learner browses catalogue May still be €49 May still be €49 No decision is made
10:00:20 Catalogue copy expires or is invalidated Redis may still serve €49 within its budget Next request obtains or validates a newer representation Authority remains €59
10:00:35 Learner begins checkout Browsing copy is not authority Browsing copy is not authority Checkout reads €59 and uses that value
10:01:00 Redis trust window ends Next browse read refills or validates €59 New reads should see €59 Authority remains €59

The intermediate states are the lesson. At 10:00:05 the system can be correct even though a browser-facing display is old, because that path was promised a 60-second freshness budget. At 10:00:35, using €49 to charge the learner would be incorrect, because checkout has a different authority contract.

Now add explicit invalidation after the write. It can shorten the browse window, but it must be treated as a distributed action with outcomes to observe:

price commit succeeds
-> publish or execute invalidation for affected copies
-> copies are removed, marked stale, or moved to a new version
-> next readers obtain the newer representation

The naive failure contrast is important:

naive claim: delete the Redis key and the price is updated
better claim: define every affected read path, then make each path obey its freshness and authority contract

So far, we have not made every copy simultaneous. We have made the allowed disagreement visible and prevented a display cache from deciding a critical action.

Choosing a Change-Propagation Policy

Expiry: simple, bounded, and deliberately old

Expiry works when the system can say, “this value may be old for this long.” It is attractive because a copy cleans itself up even if an update event is missed. It is also blunt: a change at second 1 and a change at second 59 wait differently before readers refresh.

Use it for public descriptions or catalogue summaries when the stale window is genuinely tolerable. Do not use a short TTL as a substitute for defining a price or authorization policy.

Explicit invalidation: closer to the write, more responsibility

An update can remove, refresh, or mark related copies stale. This reduces the expected stale window. It also asks the writer to know what changed: the primary object, list pages, summaries, and any derived response.

The important design question is not only “which key do we delete?” Ask “what happens if the write succeeds but invalidation is delayed, duplicated, or lost?” A TTL can be a useful backstop here, because it bounds how long a missed invalidation can survive.

Versioned copies: publish a new edition

For a content-hashed asset, a new URL points to a new immutable copy. For a generated representation, a generation number can separate the new edition from the old one. Callers move to the new name rather than trying to mutate all older copies in place.

Versioning makes transitions easier to reason about, but it does not make callers update themselves. A page that still references the old asset keeps using it until its own policy changes. Versions need clear ownership and eventual cleanup.

Validation: ask before rebuilding everything

When a copy is no longer trusted, it can ask the authority whether the representation changed. If it did not, the cache can keep using its copy; if it did, it obtains the new one. This can save bytes and recomputation for slow-changing content.

The exact HTTP validator rules belong to the HTTP and content-delivery track. Here the boundary is simpler: validation is a way to renew trust in a copy, not a guarantee that the source is available or that an action may skip its authoritative check.

When Freshness Creates Load

An invalidated or expired hot key changes the load shape. Suppose course 204 is on the homepage during the 10:00 promotion. A broad invalidation makes thousands of readers need a new response at nearly the same time.

one old popular copy becomes unusable
-> many requests miss together
-> many callers try to rebuild the same value
-> source load spikes exactly when freshness work begins

This is why “delete it and let the next request refill it” is not complete for a hot path. A design may choose to let one request rebuild while others wait, serve a briefly stale copy while one refresh happens, spread expiries with jitter, or refresh known-hot content ahead of demand. The detailed stampede mechanisms come later in the track; the requirement here is to include refill behavior in the freshness contract.

The signal to watch is not cache hit rate alone. Watch miss bursts, origin query rate, rebuild latency, request tail latency, and how many concurrent callers perform the same refill. A policy can improve freshness while creating an origin incident if it turns one update into synchronized work.

Consequences, Trade-offs, and Limits

The central trade-off is tighter freshness versus simpler, cheaper reuse. Long TTLs and broad reuse reduce origin work, but allow longer disagreement. Explicit invalidation and frequent validation reduce the expected age of copies, but add dependency edges, failure handling, and refill pressure.

A cache can be within policy and still surprise a user

“Up to 60 seconds old” may be technically valid but still confusing for a visible price change. The freshness budget belongs to a product promise, not only to infrastructure. The team may choose a shorter window, a clear timestamp, or an authoritative confirmation at the next meaningful step.

Invalidation delivery is itself fallible

An event can be delayed, duplicated, or never reach one cache boundary. Treat invalidation as a best-effort improvement unless the design also explains its repair path. TTL, validation, reconciliation, and observability can limit the damage, but none makes a multi-copy system magically atomic.

Freshness is not authorization or concurrency control

A current-looking cache entry cannot prove that an enrollment seat is still available at the moment of acceptance. The action must use the authority that can preserve the required invariant. Cache freshness improves a read path; it does not replace a transactional or guarded write decision.

This helps when readers can tolerate a stated amount of old information. It does not guarantee immediate global agreement, successful invalidation delivery, or safe write coordination. You can see the boundary when stale-content reports rise, invalidation lag grows, correlated misses increase, or an action path accidentally trusts a display copy.

Common Confusions

Confusion: A TTL is just memory cleanup

Why it is tempting:

Expiration removes old entries, so it looks like an implementation detail.

Better model:

A TTL is a promise about how long the application permits reuse without checking again. Memory cleanup is a side effect; the important choice is the allowed age of an answer.

Confusion: Explicit invalidation means no stale reads are possible

Why it is tempting:

The update path actively sends a deletion or refresh signal.

Better model:

Copies can be served before the signal arrives, the signal can miss a derived representation, and a refill can race with the write. Explicit invalidation can tighten freshness, but it needs failure assumptions and a backstop.

Confusion: A cache hit is safe evidence for checkout

Why it is tempting:

The cache contains the same kind of value that checkout needs to display.

Better model:

The browsing path and the action path can have different promises. The cache may be correct enough to display a price; checkout still needs its authoritative read before it commits.

Check Your Understanding

Check: A public catalogue promises values no more than 60 seconds old. An editor changes a description 10 seconds after a cache entry was written. Must every reader see the new description immediately?

Think first, then reveal.

Answer: No. The policy permits the existing copy to be used for the remainder of its 60-second trust window unless explicit invalidation shortens it. The right question is whether that window matches the product promise, not whether any old copy exists.

Check: A database price update succeeds, but the invalidation message for one derived catalogue response is lost. What should the design already have decided?

Think first, then reveal.

Answer: It should know the maximum stale age and repair path for that derived response: for example a bounded TTL, later validation, or reconciliation. It should also ensure checkout does not rely on that response as authority.

Check: Why can a successful invalidation make origin latency worse for a short period?

Think first, then reveal.

Answer: Many readers can miss the same popular copy at once and simultaneously rebuild it. The refresh plan must bound duplicate work or use an acceptable brief-stale policy; otherwise freshness work becomes a load spike.

Practice

Design the freshness policy for a course platform with these paths:

  1. A public course description changed by editors a few times per day.
  2. A signed-in learner's “next lesson” panel, which changes after progress is recorded.
  3. The final enrollment action when one seat remains.

For each path, write:

A strong answer distinguishes a bounded-stale display from the final enrollment decision. It names a repair path for missed invalidation and explains how popular copies avoid sending every reader to the source together.

Resources

Key Takeaways

  1. Freshness is a promise, not a timer. Decide how old a read may be before choosing the TTL or invalidation mechanism.
  2. Map the read surface, not just one key. A source change can appear in object copies, derived responses, browser or edge entries, and action paths with different contracts.
  3. Tighter freshness has a cost. Invalidation and validation add failure handling and can create correlated refill load.
  4. Display reuse does not decide an action. Critical writes must recheck the authority that owns the relevant invariant.
PREVIOUS CDN and HTTP Caching Layers NEXT Worker Pool Architecture