Cache Invalidation Patterns - Write Strategies & Consistency
LESSON
Cache Invalidation Patterns - Write Strategies & Consistency
By the end of this lesson, you will be able to...
Trace the race that can let a cache-aside reader put an old value back after a write.
Choose a cache write strategy from an explicit freshness, latency, and failure constraint.
State the signals that show whether an invalidation policy is protecting correctness or merely hiding stale reads.
Idea in one sentence: A cache copy is safe only under a freshness contract: a write strategy says who changes it, an invalidation rule says when it stops being usable, and neither removes every timing race for free.
Core Insight
At 10:00, a shop changes the price of product:42 from €20 to €24. Its database is authoritative. The product API uses a shared cache because the product page is read far more often than it is edited.
The first model is appealing: update the database, delete the cache key, and the next reader will fetch the new price. On an idle system, that is exactly what happens.
Under concurrent traffic, the word next is doing too much work. A reader may miss the cache just before the write, read €20 from the database, and be delayed before it fills the cache. The writer can then commit €24 and delete the old cache key. If the delayed reader finally stores its already-read €20, the cache contains an old value again.
The cache did not become authoritative. The system simply allowed two independent actions—reading and filling, then writing and deleting—to overlap. Invalidation is therefore not housekeeping. It is the policy that defines which copies may be served after truth changes, how repair happens, and which short periods of disagreement the product can tolerate.
The Small Situation: One Price, Three Actors
Keep the same three actors throughout:
- Database: authoritative price and a monotonically increasing
price_version. - Product API: reads the cache first and fills it on a miss.
- Editor: changes the price and triggers the chosen cache policy.
Suppose the cached value is {price: 20, version: 17}. These timestamps are illustrative, not a production trace.
cache key: product:42 -> { price: 20, version: 17 }
database row: { price: 20, version: 17 }
The business rule matters more than the storage choice. A shopper may see a product description that is a few minutes old. A price shown at checkout may need a much tighter rule, perhaps a fresh lookup or a version check before the order is accepted. “Fresh” is not one universal property of the product object; it is a promise made for a particular decision.
The Initial Model: Delete After Every Write
The usual cache-aside design is:
read: cache hit -> return copy
cache miss -> read database -> populate cache -> return value
write: update database -> delete cache key
It is a good default when the source is clear, reads greatly outnumber writes, and a brief refill delay is acceptable. The database remains the only authority. The cache is populated only for values that somebody asks for.
It is tempting to turn that into a stronger claim: “after the delete, nobody can read the old price.” That claim is false unless the read, database write, and cache update are coordinated more tightly than this pattern normally provides.
The crucial question is not whether deletion is useful. It is: which interleavings can still leave a stale copy, and is that window acceptable for this operation?
The Better Model: A Freshness Contract, Not a Magic Delete
In plain English, a freshness contract says how old a copy may be, who can make it obsolete, and what a reader must do before using it.
In this scenario, the database owns the price. The API may serve a cache copy only if that copy meets the price endpoint's freshness rule. The editor changes the authoritative row; the cache policy makes older copies unusable or replaces them.
The technical terms are write strategy and invalidation strategy. A write strategy places database and cache actions on the write path. An invalidation strategy decides whether entries expire by time, are explicitly deleted, are refreshed, or are revalidated against a version. They are related but not interchangeable:
- A TTL limits an entry's age according to the cache clock. It does not know that this particular price changed just now.
- A delete makes a known key unavailable quickly, but delivery and concurrent readers still create a race window.
- Revalidation asks whether a retained copy still represents the current version; it can avoid sending a full value when it has not changed.
For HTTP caches, a stored response is fresh for its configured lifetime and can be conditionally revalidated with validators such as ETag and If-None-Match. That is an explicit protocol contract, not an assertion that the cache has guessed the latest value. MDN documents the freshness and validation model.
A Worked Trace: How an Old Value Returns
Here is the cache-aside race. The API has just started with an empty cache; the database still has version 17.
| Time | Reader R | Editor W | Cache / database state |
|---|---|---|---|
| 10:00:00.000 | Misses product:42; reads {€20, v17} from the database. |
— | Cache empty; DB {€20, v17}. |
| 10:00:00.010 | Is paused before cache.set. |
Updates the database to {€24, v18}. |
Cache empty; DB {€24, v18}. |
| 10:00:00.020 | — | Deletes product:42 from the cache. |
Cache empty; DB {€24, v18}. |
| 10:00:00.030 | Stores its previously read {€20, v17}. |
— | Cache {€20, v17}; DB {€24, v18}. |
The delete succeeded. Yet the cache is stale because it could not cancel a reader already holding old source data. A later reader may receive €20 until another invalidation, expiry, or version-aware check corrects the copy.
There are several ways to change the contract. None is universally best.
Option A: Cache-aside with delete, TTL, and bounded repair
Keep update database -> delete cache key, give the key a bounded TTL, and accept a named stale window. Protect a hot miss with request coalescing or a per-key fill lock so a purge does not cause every request to query the database.
This is often a good fit for product metadata when stale display is tolerable for, say, 30 seconds but an origin surge is not. The short TTL is a safety net, not evidence that the delete reached every layer or that the cache can never be stale. Record the allowed window in the endpoint contract.
Option B: Write-through with an acknowledged order
A write-through path updates the cache as part of the write operation. It can make the new cache value available immediately after the write succeeds:
validate request -> commit DB {€24, v18} -> set cache {€24, v18} -> acknowledge
This improves the normal post-write read path. It costs write latency and adds a failure decision: what should the API report if the database commit succeeds but cache.set times out? Retrying a cache update may be fine when the value contains a version; blindly retrying an older payload is not. A version-aware cache can reject a write for version 17 when it already holds version 18.
That version rule is a teaching model, not a claim that every cache offers it natively. It means the application or cache interface must make ordering explicit if ordering is required.
Option C: Write-behind when the cache accepts the write first
Write-behind, also called write-back, acknowledges a cache update and flushes it to durable storage later. It can absorb a burst and shorten visible write latency. But it changes the authority boundary: during the flush delay, the database is no longer the latest durable record of the accepted update.
Use it only when the product can state how it handles a cache-node failure, flush retries, ordering, duplicate writes, and recovery. A price change that must survive a process loss is a poor place to silently assume those guarantees. Faster acknowledgement is bought with a larger durability and recovery obligation.
Choosing by the Promise, Not by the Pattern Name
Start from the decision that consumes the value.
| Constraint | A reasonable starting policy | What still needs evidence |
|---|---|---|
| Product card may be briefly stale; reads dominate. | Cache-aside, delete after source commit, finite TTL, coalesced refill. | Stale-read age, invalidation lag, hit rate, origin refill rate. |
| A displayed value must match the just-acknowledged edit for the same request path. | Write-through or a read-after-write route that reads the authoritative version. | Cache update failures, version ordering, write latency. |
| Repeated HTTP content can be reused but should not transfer its body when unchanged. | Finite freshness plus conditional revalidation with a validator. | Validator correctness, 304 rate, origin validation load. |
| A burst needs fast acceptance before durable persistence. | Write-behind only with an explicit durable queue/recovery design. | Flush lag, failed flushes, replay safety, data-loss exposure. |
This table gives preferences under constraints, not laws. A cache-aside delete might be right for one endpoint and unsafe for another endpoint that shares the same database row. The endpoint's user promise decides the acceptable stale window.
Where the Policy Breaks
Invalidation improves reuse without declaring copies perfect. Watch these boundaries:
- Multi-layer copies: deleting the application cache does not remove a browser, proxy, or CDN copy. Each layer needs its own key and freshness contract.
- Lost or delayed invalidations: an event-based delete needs delivery, retry, and observation. Treat it as a distributed message, not a local function call.
- Refill stampede: a successful purge can still overload the source if many readers refill together. Measure miss concurrency, not only hit rate.
- Wrong key scope: a cached response that ignores a relevant language, tenant, or authorization dimension can be fresh and still be the wrong response. Shared caches require an explicit sharing boundary.
- Ordering: a delayed update can overwrite a newer cache value unless a version, generation, or compare-and-set rule rejects it.
The trade-off is now visible. More aggressive reuse reduces source work and latency. Stronger freshness adds writes, validation traffic, coordination, or operational state. The policy does not solve an unavailable database, a wrong authorization check, or a hot key by itself. The signals to watch are cache hit rate by key class, source read rate after purges, invalidation delivery lag and failures, stale-version detections, and p95/p99 latency around writes.
Check: An editor commits version 18, publishes an invalidation event, and the API cache misses. A reader then receives version 17. Which explanation should you investigate first: “the TTL is too long” or “a concurrent fill or another cache layer reintroduced/served the old copy”? Why?
Think first, then reveal.
Answer: Investigate the trace and cache layers first. A miss immediately after an invalidation does not prove the TTL served the old entry. The trace may show a reader that fetched version 17 before the write and filled late, or a different layer with a separate key and freshness rule. TTL may bound repair later, but it does not identify the path that produced this stale response.
Practice: Write the Contract for a Price Change
The shop adds a seller dashboard. An editor changes a product price. The public catalog may show the old price for up to 60 seconds, but the dashboard must show the editor's new price immediately after a successful save. A CDN may cache public catalog cards; the dashboard is private.
Design the smallest policy that meets both promises. Name the authority, one invalidation action, one reason to keep a version, and two signals you would alert on.
A good answer should mention:
- the database as the authoritative store and a committed new version before public cache invalidation;
- a public catalog cache-aside delete or versioned refresh with a maximum 60-second fallback TTL, plus a CDN policy of its own;
- a dashboard read-after-write path that returns the committed version or a cache update guarded by version/order, rather than trusting a public shared copy;
- explicit
privatetreatment for dashboard responses so shared caches cannot reuse them for another editor; and - invalidation lag/failure, stale-version observations, source reads after purge, or tail latency as operational signals.
Connections
The previous lesson separated cache placement from cache freshness: a hash ring decides which node receives a key; this lesson decides whether that node's copy is usable. The next lesson moves the same freshness question to CDN edges, where cache keys and invalidation must remain correct across geography.
Resources
- [ARTICLE] HTTP caching — Focus: Read fresh/stale lifetime, validators, conditional requests, and the distinction between
no-cacheandno-store. - [ARTICLE] Caching challenges and strategies — Focus: Compare common cache strategies and their failure trade-offs in a production setting.
- [DOCS] CloudFront invalidations — Focus: Treat a CDN purge as a separate invalidation mechanism with its own scope and propagation behavior.
Key Takeaways
- Cache invalidation is a freshness contract between authoritative state, copies, and the readers that consume those copies.
update database -> delete cacheis useful cache-aside discipline, but a concurrent old read can still fill the cache after the delete.- TTL, explicit deletion, and revalidation solve different parts of the freshness problem; combine them only when their costs match the promise.
- Write-through improves normal post-write reads but makes cache-update failure and ordering part of the write contract; write-behind adds an even larger durability obligation.
- Measure invalidation lag, stale-version observations, refill pressure, and tail latency before calling a cache policy correct enough.
← Back to Caching, Workers, and Performance