Article

Stale-while-revalidate

Caching a restaurant is easy.


Caching its menu, its availability, a search result containing it, and a slightly different view of the same thing is also easy.


Changing the restaurant afterwards is where the conversation becomes less cheerful.


The ecoeats cache layer used stale-while-revalidate and an entity registry: cached results could declare which entities they depended on, so a change could invalidate more than one hand-picked key.


Nothing mystical. Just keeping a list of the things we'd otherwise forget.



The views and identifiers are illustrative. This is a conceptual dependency graph, not a production key inventory.

A cache key describes a question

restaurant:42

sounds like a perfectly good key until one caller means “load this restaurant” and another means “load it only if it is open”.


Those are different questions. Sharing a key doesn't make their answers equivalent.


The original cache service documented this problem explicitly. Arguments and query semantics belong in the key. So do relevant boundaries such as locale, permission scope, or tenant context where those affect the answer.


A well-named entity isn't enough. The key needs to identify the result you actually computed.

Fresh, stale, and too old are different states

Stale-while-revalidate separates two concerns: whether cached data can still be served, and whether a refresh should begin.


Fresh data can be returned directly. Stale-but-acceptable data can be returned while a refresh runs. Data beyond the allowed maximum age should no longer be treated as an acceptable answer without a successful refresh, unless you've explicitly designed a stale-on-error policy.


Here is a simplified policy sketch, not a complete implementation:


no cached value → fetch before answering
age below fresh limit → return cached value
age below max limit → return cached value; start deduplicated refresh
age beyond max limit → wait for fresh data or apply explicit failure policy

Those limits should come from the use case. A description can tolerate different staleness from a price or an authorization decision. “We cache everything for a minute” is a configuration, not a reason.

Register the dependency when you create the result

The entity registry gives us a reverse lookup:


entity:restaurant:example-42
→ menu-view:example-42:en
→ restaurant-card:example-42
→ availability-view:example-42:delivery

When a cached result is written, register its dependencies. When the entity changes, look up those result keys and invalidate them.


The historical implementation used Redis-backed registry membership and deletion. Its single-key API distinguished soft and hard invalidation, while entity invalidation was implemented as hard deletion. Those are separate capabilities; a general diagram shouldn't imply both modes existed for every path.


Soft invalidation marks a value stale so the next reader can receive it while a refresh starts. Hard invalidation removes the value so the next read must fetch. Neither is universally correct.

The missing dependency is often a query

An entity registry handles “this result contains restaurant 42” quite nicely.


What about a search result that should now contain restaurant 42 but didn't contain it before?


There was no entity membership to register. Likewise, changing a sorting field may affect a result page the entity never appeared on.


For those cases, you need broader dependencies such as a collection or query-scope tag, versioned query keys, or bounded expiry. The registry doesn't discover relationships by magic. Somebody still has to model the invalidation domain.


That is the part worth discussing before the Redis code.

Deleting a key doesn't stop an old writer

Imagine a refresh starts and reads an old value. Then a write commits and invalidates the key. Finally, the refresh completes and writes its old answer back.


Congratulations: the cache is populated again, and still wrong.


A more robust design can use entity generations or source revisions, checking that the generation hasn't changed before storing the refresh result. Compare-and-set logic, transactional outbox processing, or other mechanisms may be appropriate depending on the consistency you need.


A lock used to deduplicate refreshes is useful, but isn't automatically a solution to every race. The database write and the cache have different lifecycles.


I'm describing improvements and design choices here, not claiming the original wrapper solved them all.

Bookkeeping also has a cost

Registry entries need cleanup. A popular entity may depend on many cached results, making invalidation an expensive fan-out operation. An atomic script can still block Redis for too long if you give it an enormous amount of work.


Bound membership, observe invalidation size, and decide how expired results leave the registry. For large dependency sets, generation-based keys can trade immediate deletion for later cleanup.


Test concurrent refresh and invalidation, a failed refresh, a missing registry entry, and a collection result gaining a new member. These are more revealing than checking that

get

returns whatever

set

just stored.

Make staleness a decision

The point of a registry isn't to declare cache invalidation solved. It's to make the dependencies visible enough to reason about.


Name the question. Register the things its answer depends on. Decide which stale answers are acceptable. Preserve the right to say that some answers must be fresh.


The difficult part isn't remembering how to delete a Redis key. It's remembering which question that key was answering.

Made with Intenso