A customer changes a delivery address, receives a successful response, and then sees the old address when the page reloads. The database contains the new value. The application logs show a successful update. The problem is a cached representation that no longer describes the authoritative record.
Cache invalidation becomes difficult when several copies exist, writes race with reads, and the messages that announce changes can be delayed or lost. Deleting one key after an update solves the happy path, but it does not by itself define what readers will observe during failure.
The useful starting point is a consistency contract: which data may be stale, for how long, and for whom? From there we can choose an invalidation protocol whose failure behaviour matches the product.
Introduction
Consider an online shop with a product database, a shared Redis cache, local caches inside application instances, and a content delivery network serving public product pages. A product change can affect its detail page, category listings, search results and price summaries.
These representations do not necessarily need identical freshness. A descriptive paragraph can usually tolerate a short delay. A checkout calculation must validate the current applicable price and stock rules at the authoritative service. A customer's private address must never be returned from another customer's cache entry, regardless of freshness.
We will use this shop to examine cache-aside races, durable invalidation events, versioned values, multi-layer caching and repair. The focus is consistency after change. Preventing many simultaneous misses from overwhelming the database is a related capacity problem, but request coalescing alone does not make cached data correct.
Define Freshness as Part of the API Contract
“The cache is eventually consistent” leaves too much unspecified. Does the customer who performed the update immediately see it? Can another region display an old value for seconds or minutes? What should happen when the invalidation pipeline is unavailable for an hour?
Group data by its consequences. Public descriptive content may have a bounded staleness target. A dashboard may display its refresh time. Permission changes may require immediate checks against authoritative policy. Transactional decisions should generally validate the relevant state when committing the transaction.
The same entity can have several contracts. A product listing might show an approximate stock label, while checkout attempts an atomic reservation. Keeping the listing perfectly current would not eliminate the race between displaying the page and placing the order anyway.
Document whether a stale response is permitted and whether the response reveals its age or version. A version helps clients detect regression and lets operators compare a cached value with the database without relying on wall-clock guesses.
Treat availability during an outage as a separate decision. Serving a previously validated public description may be acceptable. Serving a cached permission grant after access has been revoked may violate the application's security boundary. A generic “serve stale on error” setting should not decide both cases accidentally.
Understand the Basic Cache-Aside Flow
In cache-aside, a read checks the cache, loads the database on a miss, and stores the result for later requests. A write changes the database and then removes or updates the cached representation.
The Microsoft cache-aside pattern describes this arrangement and its consistency limitations. The cache is a derived copy, so every path that changes the source must participate in the refresh strategy or rely on a bounded fallback such as expiry.
For the shop, a straightforward read might use product:42:public:v3, where v3 identifies the cache representation schema. The stored value contains the product fields, source version and creation time. A short expiry limits how long a missed invalidation can remain visible under the assumed read and refill behaviour.
The schema suffix should not be confused with the product's data version. Schema version 3 describes how the cached object is encoded. Product version 817 describes which committed product state it represents. Both can matter during deployments and updates.
Keep the database authoritative. If losing the cache would lose an accepted product update, the component is acting as a write store and needs a different durability and recovery design. Calling it a cache does not make that responsibility disappear.
Why Delete Before the Database Update Is Unsafe
Suppose the writer deletes the cached product and then updates the database. Between those operations, a reader misses the cache and fetches the old database value. The reader caches it, and the writer commits the new value afterwards.
The invalidation succeeded, yet the cache now contains old data. The remaining expiry determines how long readers can continue observing that value. Moving the deletion before the write has opened a refill window against the previous source state.
Updating the database first and invalidating afterwards is usually a better starting order. Readers that miss after the commit can retrieve the new state, provided they read a source that has applied the write.
However, a crash between commit and invalidation still leaves an old entry. A successful HTTP response cannot be the only evidence that invalidation occurred, because the process may disappear before sending that response or immediately after committing the transaction.
The ordering decision therefore solves one race without solving every failure. We need to consider in-flight reads that started before the commit, source replicas that lag, and durable delivery of the invalidation itself.
The Stale Refill Race After a Successful Delete
A more subtle sequence begins when a reader misses the cache and reads product version 817. Before that reader stores its result, another request commits version 818 and deletes the cache entry.
The original reader then resumes and stores version 817. Future requests see the old version even though the database update and cache deletion both completed successfully. No network outage is required; ordinary request scheduling can produce the sequence.
Deleting the key a second time after a delay reduces the chance of this outcome if the delay exceeds every relevant read duration. It is not a general proof of correctness. A request can be delayed longer than expected, and the second deletion can itself fail.
Reading from an asynchronously replicated database creates another refill problem. Even a read that begins after invalidation can retrieve version 817 from a lagging replica. Repeated expiry and refill can keep producing stale data until the source catches up.
This is why a time to live alone is not an unconditional freshness guarantee. A five-minute cache lifetime bounds the lifetime of that entry, not necessarily the age of the value repeatedly loaded into it. A meaningful end-to-end bound must include source freshness, in-flight work and failure recovery.
Use Versions to Reject Regressive Cache Writes
Attach a monotonically increasing entity version to each committed product change. A cache update can then accept a value only if its version is at least as recent as the existing value. The comparison and replacement must be atomic within the cache.
This prevents a delayed version 817 write from replacing a cached version 818. It does not help if invalidation simply deletes all evidence of version 818 before the delayed write arrives. Absence contains no information about the minimum version that is now acceptable.
One approach keeps a version watermark or invalidation tombstone. When product 42 changes to version 818, the invalidator records that lower versions are obsolete. A reader trying to refill with 817 is rejected and must fetch sufficiently current data or avoid caching its response.
The watermark itself needs a lifecycle. If it expires before a delayed fill can arrive, that fill may be accepted again. Bound the lifetime of in-flight operations, retain watermarks appropriately, or use a protocol that validates against authoritative version information when needed.
The following pseudocode expresses the cache-side requirement:
atomic cache operation for product 42:
minimum = stored invalidation version
current = stored value version
reject candidate if candidate.version < minimum
reject candidate if candidate.version < current
otherwise store candidate with expiry
This is a protocol sketch, not a multi-command client implementation. Executing separate reads and writes would allow another update between the comparison and replacement. Choose a cache transaction or script with the required atomicity for the relevant key placement.
Versioned Keys Change the Problem
Another design stores immutable representations under keys such as product:42:version:818. A stale reader can write version 817 without overwriting version 818 because the values have different identities.
The challenge moves to discovering which version should be read. If product:42:current is cached and stale, the application can still select version 817. The pointer needs its own invalidation, conditional update or authoritative lookup.
Versioned keys are particularly useful when the caller already knows the required version. A successful edit response can include product version 818, and the follow-up read can request a representation at least that recent. An immutable image URL can include a content hash that changes whenever the image changes.
Old versions need retention and cleanup rules. Keeping every representation forever is unnecessary for an ordinary cache, but deleting them too aggressively may break clients with valid references. Align retention with the API contract rather than assuming every key is immediately disposable.
Use versioned keys when they simplify a real dependency, such as immutable assets or snapshot reads. Do not add an authoritative pointer lookup to every cache hit without measuring whether the resulting design still provides the latency and load reduction that justified caching.
Make Invalidation Delivery Durable
Updating the database and publishing an invalidation message are two writes to different systems. If the process commits the product change and crashes before sending the message, subscribers never learn about the update.
A transactional outbox closes that local durability gap. The product update and an outbox record are committed in the same database transaction. A separate dispatcher delivers the record to a durable messaging system and retries until delivery is acknowledged.
The event can contain the product identifier, committed version, event identifier and the kinds of derived views affected. Avoid including private or large product data unless consumers need it. A small versioned change notification is often enough to evict or refresh representations.
Delivery may occur more than once. Consumers should make invalidation idempotent and track progress durably where replay matters. Removing a key repeatedly is harmless, but updating a version pointer requires rejecting events older than its current value.
The AWS transactional outbox guidance explains the dual-write failure this pattern addresses. It does not remove propagation delay. Readers can still observe old values between the database commit and event processing unless another mechanism provides their required consistency.
Events Need Ordering and Deletion Semantics
Suppose events for versions 818 and 819 arrive out of order. An invalidator that only deletes keys remains broadly safe from the event ordering itself, although stale refills are still possible. A consumer that writes event payloads directly into the cache could regress from 819 to 818 without a version comparison.
Define whether versions are ordered per entity, per aggregate or globally. Per-product ordering is usually enough for a product detail cache. Comparing unrelated sequence numbers from different shards as though they form one global order can produce meaningless decisions.
Deletion requires a versioned event too. Otherwise an old update event can recreate cached data after the database record was deleted. Retain a deletion marker or resource incarnation that lets consumers recognise obsolete messages.
If an identifier can be reused, distinguish the new entity from the deleted one. A recreated product with the same external code should not inherit an old cache entry or delayed update. An immutable internal identifier and a separate display code make this easier.
Schema changes also affect replay. Keep enough event version information to interpret older messages, or make the consumer reload current source state instead of applying historic payloads blindly. Replaying an invalidation log should repair cache freshness without resurrecting an obsolete representation format.
Coordinate Local, Shared and Edge Caches
The shop may invalidate Redis correctly while an application instance continues serving its own in-memory copy. An edge cache can add another layer after the application has already returned fresh data.
Map every cache between the caller and the authoritative source. For each layer, record its key, lifetime, invalidation mechanism, freshness metadata and behaviour during disconnection. A diagram is useful, but the operational contract for each arrow matters more than the number of boxes.
Local caches can subscribe to invalidation messages and use short expiry as a repair mechanism. If a subscription disconnects, clear or age out affected entries before treating the instance as current again. A missed interval should not be invisible just because the connection eventually recovers.
Redis explicitly documents that keyspace notifications use fire-and-forget Pub/Sub. They can support responsive invalidation, but a disconnected subscriber can miss events. They should not be mistaken for a durable change log with replay guarantees.
For a content delivery network, use documented purge or surrogate-key capabilities when available and retain sensible cache headers. A successful origin update does not prove that every edge has completed a purge. If immediate freshness is essential for an editor, provide a private preview or a version-specific URL whose semantics are explicit.
Treat HTTP Validation as a Separate Layer
An entity tag lets a client ask whether its stored representation is still valid. If the representation has not changed, the server can return a response without retransmitting the full body. This saves bandwidth while allowing the server to participate in validation.
The entity tag should correspond to the selected representation, including relevant variants. A product's translated page or currency-specific price can differ even when its base record identifier is the same. Cache keys and validators must account for those dimensions consistently.
The HTTP caching specification defines freshness, validation and invalidation rules for HTTP caches. An important practical distinction is that no-cache permits storage but requires appropriate validation before reuse, whereas no-store directs caches not to store the response.
Do not assume that invalidating a product's detail URI also invalidates category pages, search results or unrelated API endpoints that contain the same data. Those are separate cached representations, and application dependencies often extend beyond the target of a single HTTP request.
Authenticated and personalised responses require deliberate cache policies. Including a user identifier in a Redis key does not configure a shared HTTP cache correctly. Review headers, variation and authorisation at each layer where content can be reused between requests.
Invalidate Derived Views Without Losing Control of Fan-Out
A product update may affect hundreds of category, filter and recommendation results. Keeping a complete reverse index from every product to every cached query can become expensive and difficult to maintain.
Start by separating fields according to their dependencies. A description update may affect a detail page but not the membership of a price-filtered category. A price change can alter ordering and membership across many queries. A removal can affect almost every public representation of the product.
For common finite groups, maintain explicit tags or group generations. A category generation change causes new requests to use a new key namespace while older entries expire naturally. The generation pointer still needs reliable propagation, but invalidation no longer requires enumerating every cached filter combination.
For highly variable queries, shorter lifetimes or uncached assembly from cached entities may be more practical. The application can cache product records and compute a smaller result set, rather than storing every possible user-generated query indefinitely.
Measure invalidation fan-out and the database load that follows it. A global purge can be logically correct yet operationally destructive if it removes all useful cache protection at peak traffic. Stage broad rebuilds, preserve immutable unaffected values, and reserve capacity for refill.
Give Writers Read-Your-Writes Where It Matters
After a customer edits an address, returning the saved representation directly avoids an unnecessary immediate read. The response can include a source version that the client retains for the next request.
A subsequent request carrying a minimum required version can bypass cache entries below that version. It may read from the authoritative database or wait briefly for a sufficiently current representation. The server must validate any client-supplied context and keep the waiting time bounded.
This provides a targeted guarantee for the writer without making every anonymous catalogue request strongly consistent. It is especially useful when most traffic tolerates brief staleness but an edit flow would feel broken if it displayed the previous value.
Do not forget lower layers. Bypassing Redis while reading from a lagging database replica can still return old data. A complete read-your-writes path needs a source that has applied the relevant write, and an HTTP cache must not intercept the request with a weaker response.
Cross-device behaviour is another product choice. A browser-local version marker does not automatically follow a customer to their phone. If the guarantee must span devices, store the required session or account context server-side and define when that requirement can safely expire.
Repair Missed Invalidations and Measure Freshness
An invalidation system needs a recovery path beyond restarting its consumer. Durable event replay repairs gaps when the log still contains the required history. A periodic reconciliation job can sample cached versions against authoritative versions and identify missed dependencies or broken consumers.
Track event age from database commit to successful processing, along with queue depth and retry counts. Also measure observed staleness, because a healthy consumer does not prove that every application instance uses the correct key or handles every change type.
Sample cache entries by entity type and compare versions without creating a heavy read workload. Record whether stale values came from missed events, lagging source replicas, delayed refills or forgotten local caches. These categories lead to different fixes.
When a consumer falls far behind, an operator may choose to invalidate a namespace and restart from a safe checkpoint rather than replay millions of obsolete changes. That decision needs enough source capacity for refill and a clear rule for events arriving during the reset.
Do not advance a durable checkpoint until the corresponding cache action has succeeded or been safely superseded. If processing is parallel, ensure checkpoint advancement cannot skip a failed earlier event in the same ordered stream.
Test Interleavings and Deployment Changes
Build a deterministic test that pauses a cache-miss reader after it loads version 817. Commit version 818, process its invalidation, then allow the old reader to fill the cache. Verify the selected protocol rejects or bounds that stale value as intended.
Test duplicate and out-of-order events, deletion followed by delayed updates, consumer disconnection, and a database replica that remains behind after cache expiry. These cases expose assumptions that ordinary “update then read” tests rarely exercise.
During a rolling deployment, old and new application versions may share the cache. Use representation schema versions when their serialised values are incompatible. Ensure both versions can process the relevant invalidation events while they overlap, or isolate their namespaces deliberately.
Test the operational repair procedure too. Purge a namespace in a representative environment, replay from a checkpoint and measure refill load. A recovery plan that only works when the database has unlimited spare capacity is not yet a dependable plan.
Finally, test negative caching. A cached “product not found” result can hide a newly created product just as effectively as an old positive value. Creation events, sensible negative lifetimes and resource identity rules must all include that representation.
Choose the Smallest Protocol That Meets the Contract
A small catalogue with infrequent edits may work well with database-first updates, post-commit invalidation, short expiry and authoritative checkout validation. The failure window is understood, inexpensive to repair and acceptable for its public pages.
A collaborative editing tool may require source versions, read-your-writes tokens and conditional cache updates because users immediately depend on their own changes. A permission service may avoid caching sensitive allow decisions or use a carefully designed revocation mechanism with explicit availability tradeoffs.
Complexity should follow the consequence of staleness. Adding durable events, watermarks, versioned keys and dependency graphs everywhere can create more operational failure modes than the original cache. Leaving every data type behind a single arbitrary expiry creates the opposite problem.
Write down the chosen contract beside the implementation. State the authoritative source, permitted staleness, invalidation path, refill source and repair mechanism. A future developer changing the database access path can then see why reading from a convenient replica may weaken the guarantee.
Walk Through a Product Price Change
Assume product 42 changes from version 818 to 819. The product service validates the editor's expected version, updates the authoritative row, and stores an outbox event in the same transaction. The response contains version 819 and the resulting representation, so the editing screen can show the accepted change immediately.
The dispatcher delivers the event to cache consumers. The shared cache advances the product's minimum acceptable version and removes any older value. Local application caches receive the same change, while the catalogue page cache invalidates the relevant product and category groups. These steps are observable individually because their completion times can differ.
A reader that had already loaded version 818 tries to refill the shared cache. The atomic version check rejects the candidate. If that reader is serving an ordinary public catalogue request whose contract permits brief staleness, it may still return its already loaded result without caching it. If it carries a minimum required version of 819, it must obtain newer data or return the defined temporary failure instead.
Meanwhile, a category page at the edge may continue displaying the previous price within its permitted freshness interval. Checkout does not trust that display value. It asks the pricing service for a current quote and applies the order's price agreement rules when the customer confirms. The cache contract and transaction contract remain explicit at their respective boundaries.
If the invalidation consumer crashes after advancing the watermark but before acknowledging the event, replay repeats the operation safely. If it crashes before either step, the durable message remains pending. If the event system is unavailable entirely, monitoring exposes increasing event age and expiry provides the documented fallback, subject to the refill source being sufficiently current.
This walkthrough identifies the exact places where stale data can still appear. It also shows which observations constitute a defect: a regressive cache overwrite, an editor receiving an older version despite its requirement, or a checkout accepting a price outside its authoritative rules. Those are clearer targets than demanding that every copy change simultaneously.
Summary
Cache invalidation is a consistency protocol across copies of data. The hard cases involve writes racing with fills, messages arriving late, sources returning old versions and caches existing at several layers.
Start by defining acceptable staleness and the authoritative boundary for important decisions. Use versions to identify regression, durable delivery to repair missed changes, and conditional updates or immutable keys where the contract requires stronger behaviour. Include deletion, negative results and derived queries in the design.
Then verify the failure paths with controlled interleavings and measure freshness in production. A cache can remain valuable without promising perfect synchronisation everywhere, provided the application makes its guarantees explicit and can recover when copies diverge.
