
A customer changes a product price, the database commit succeeds, and the product page still shows yesterday’s amount. Nothing is wrong with the query. The problem is the older copy in one of several caches. Cache invalidation strategies define when that copy stops being trusted and how every cache layer learns about the change.
This guide builds on cache-aside and write-through versus write-back caching. It explains the practical choices behind TTL expiry, delete-on-write, event-driven invalidation, tags, and versioned keys, including the races that make an apparently simple DEL unsafe.
Table of Contents
Open Table of Contents
- What Cache Invalidation Means
- Start With a Freshness Contract
- The Main Cache Invalidation Strategies
- Delete-on-Write Is a Useful Default
- Race Conditions and How to Limit Them
- Event-Driven Invalidation for Many Cache Layers
- Keys, Tags, and Versioning
- Choosing a Strategy
- Operational Guardrails
- Interview Questions
- Conclusion
- References
- YouTube Videos
What Cache Invalidation Means
Invalidation is the act of making a cached representation ineligible to answer a future request after its source of truth has changed. It is not the same as eviction. Eviction is a cache making room under memory pressure; invalidation is an application correctness decision. A value can be present, unexpired, and still wrong.
The hard part is that one logical fact can be copied into several shapes. Updating product 42 might affect product:42, a category listing, a search result, an API response, an HTML page at the CDN, and a browser response. Deleting one key does not automatically identify every derived representation. That is why a cache key is part of the system’s data model, not a string assembled at the last minute.
For a refresher on memory pressure, expiry, and LRU/LFU choices, read cache eviction policies. Eviction can reduce stale data by accident, but it must never be the mechanism that guarantees freshness.
Start With a Freshness Contract
Do not start with Redis commands. Start by stating what stale data can do to a user and how long that is acceptable. A homepage popularity counter can be 30 seconds behind. Inventory available for checkout may need a database transaction as the decision point, with its cache used only for a hint. A price visible before payment may tolerate a short delay if checkout revalidates it.
| Data and operation | Example freshness budget | Safer default |
|---|---|---|
| Editorial page | Minutes or hours | TTL plus CDN revalidation |
| Product details | Seconds to a few minutes | Delete-on-write plus TTL backstop |
| Dashboard aggregate | Seconds | Scheduled or event-driven refresh |
| Authorization or entitlement | Near zero for revocation | Authoritative check or immediate event invalidation |
| Inventory reservation or payment | No cache decision for the invariant | Transactional source of truth |
A freshness budget is not a promise of strong consistency. It is an explicit product decision: “this endpoint may return a value up to N seconds old.” Once the budget is written down, the TTL, retry policy, monitoring alert, and fallback behavior become testable rather than folklore.
The Main Cache Invalidation Strategies
Most production systems combine strategies. The question is which mechanism provides the normal path and which one limits damage when a message is lost or a deploy is incomplete.
TTL Expiry
A time-to-live removes or expires an entry after a fixed age. It is the simplest option because no writer must know every cache key. The trade-off is deliberate staleness: an update immediately after population can remain invisible for almost the entire TTL.
Key: product:42
Value cached at: 12:00:00
TTL: 300 seconds
Database price changes at: 12:00:01
Maximum stale response: almost five minutes
Use TTL alone for data whose maximum age is genuinely acceptable. Add random jitter when many keys are created together, otherwise a synchronized expiry can overload the source of truth. TTL is also a valuable backstop for explicit invalidation: it eventually removes a stale value if a delete event is missed.
Delete-on-Write
With cache-aside, a successful database write deletes the affected cache key. The next read misses, loads the fresh source value, and repopulates the cache. This is usually safer than trying to construct the new cached value in every write path, because a delete does not need to duplicate the full read projection.
The price is a brief miss after each write and a requirement to identify every affected key. A product update may invalidate both product:42 and a list page whose membership, sort order, or filter result changed. Deleting only the detail key creates a cache that is locally correct but globally inconsistent.
Write-Through Refresh
Write-through updates the source of truth and cache as part of the write workflow. It can minimize stale reads after a successful update, but it does not create a distributed transaction. A database commit can succeed while the cache write times out, or the reverse ordering can expose an uncommitted value. The repair policy matters more than the optimistic name of the pattern.
For the full write-path trade-off, see write-through versus write-back caching. In many applications, commit the database first, attempt a cache refresh, and delete the key on refresh failure so a later read rebuilds it from the authoritative record.
Event-Driven Invalidation
An event-driven design publishes a durable statement such as ProductPriceChanged after the database commit. Consumers invalidate the application cache, CDN paths, search projection, or local in-process cache that they own. This is useful when many services have independent cached views, because the writer does not need a synchronous connection to each cache.
It introduces delivery concerns: events can be delayed, duplicated, delivered out of order, or lost if they are published separately from the database transaction. Use an outbox record written in the same transaction as the business change, then publish that record asynchronously. Consumers must be idempotent: deleting the same cache key twice is safe and therefore a good event handler operation.
flowchart TD
A[Update request] --> B[Application transaction]
B --> C[Primary database]
B --> D[Outbox record]
D --> E[Outbox publisher]
E --> F[Change event]
F --> G[Application cache consumer]
F --> H[CDN purge consumer]
F --> I[Search projection consumer]
G --> J[Delete or version cache keys]
H --> K[Invalidate derived pages]
I --> L[Refresh derived documents]
E -->|Retry after failure| D
Tags and Versioned Keys
Tags map a broad dependency, such as product:42 or category:shoes, to many cached representations. Invalidating a tag can purge every response that declared it. This works well for page and CDN caches, but the index must be bounded and maintained correctly. A wildcard key scan in Redis is usually an operational trap because it grows with the cache instead of the update.
Versioned keys avoid deleting every dependent object. Store a version for a dependency and incorporate it into keys:
product:42:version = 17
product:42:v17
category:shoes:v91:page:1
When product 42 changes, increment its version. New reads use v18; old keys become unreachable and expire naturally. This makes reads deterministic and reduces delete races, but creates temporary orphaned entries. It is a good fit when reads can cheaply obtain the current version and storage growth is bounded by TTL.
Delete-on-Write Is a Useful Default
For an ordinary detail endpoint, a robust baseline is: commit the source-of-truth update, delete the specific cache key, and retain a TTL. The ordering matters. Deleting before the transaction commits creates a window where a concurrent reader misses and repopulates the cache with the old database value.
flowchart TD
A[Client changes product] --> B[Validate request]
B --> C[Commit database transaction]
C --> D[Delete product cache key]
D --> E[Return success]
F[Later reader] --> G{Cache hit?}
G -->|No| H[Read committed database value]
H --> I[Set key with TTL]
I --> J[Return fresh value]
G -->|Yes| J
D -->|Delete failure| K[Record error and retry]
Here is a TypeScript-shaped example. It intentionally treats a failed invalidation as an operational failure to repair, not proof that the database update failed.
async function updateProductPrice(productId: string, priceCents: number) {
const product = await database.transaction(async tx => {
return tx.product.update({
where: { id: productId },
data: { priceCents },
});
});
try {
// Delete only after the authoritative update is committed.
await redis.del(`product:${productId}`);
} catch (error) {
logger.error(
{ error, productId },
"Cache invalidation failed after commit"
);
await invalidationRetryQueue.enqueue({ type: "product", productId });
}
return product;
}
The retry needs a deadline, visibility, and idempotence. Retrying a DEL is safe. Retrying the business update without an idempotency boundary may not be. Keep those two concerns separate.
Race Conditions and How to Limit Them
Delete-after-commit narrows the stale window but does not eliminate every race. Consider this timeline:
- Reader A misses
product:42and reads the old database value. - Writer B commits a new price and deletes
product:42. - Reader A finishes late and writes the old value to
product:42.
The delete happened, yet the cache contains stale data again. A TTL bounds the damage but does not prevent it. Choose a mitigation based on the freshness contract:
| Technique | What it prevents | Cost and limitation |
|---|---|---|
| Short TTL | Limits lifetime of a stale reinsert | More source reads and still stale within the TTL |
| Versioned key | Makes old writes target an old version | Requires version lookup and cleanup by TTL |
| Compare-and-set | Rejects a write that has an older version | Needs cache support and a reliable version field |
| Update event consumer | Deletes stale copies after the event | Delivery lag and outbox complexity remain |
| Per-key request coalescing | Reduces duplicate rebuilds on a miss | Does not solve a stale source read by itself |
For high-value mutable objects, include an authoritative updatedAt or monotonically increasing version in the cached payload. A cache writer should not overwrite a newer version with an older one. Do not assume timestamps from independent machines establish a total order; use a database-generated version or commit sequence when ordering matters.
Event-Driven Invalidation for Many Cache Layers
Event-driven invalidation is most valuable when a write affects different owners: one service caches JSON in Redis, another generates a search document, and an edge provider caches a rendered page. An outbox pattern turns the database transaction into the source of the event’s truth.
BEGIN;
UPDATE products
SET price_cents = 2499, version = version + 1
WHERE id = '42';
INSERT INTO outbox_events (event_id, topic, aggregate_id, payload, created_at)
VALUES (
'018f...',
'product.changed',
'42',
'{"productId":"42","version":18}',
NOW()
);
COMMIT;
The publisher can retry an unprocessed outbox row until the broker acknowledges it. A consumer records event IDs or relies on its idempotent operation before acknowledging. The event payload should contain the identity and version needed to invalidate a representation; publishing an entire mutable object increases coupling and can recreate the same stale-data problem in another service.
An event cannot make a browser or CDN instantly consistent. It establishes a measurable propagation path. Instrument the lag from database commit to cache deletion, alert when it exceeds the freshness budget, and retain a TTL as a safety net. For Redis topology and reliability constraints, use the Redis architecture guide.
Keys, Tags, and Versioning
Before choosing a command, write a dependency map. For a product price change, list direct keys and derived keys, their owners, and their freshness contracts:
| Representation | Owner | Invalidation action |
|---|---|---|
product:42 | Product API | Delete after commit |
category:shoes:page:1 | Catalog API | Delete or bump category version if ordering changes |
/products/42 | CDN | Purge exact URL or tag |
| Search document | Search indexer | Consume product change event |
Use namespaced keys with tenant, locale, and projection details where relevant. product:42 is not safe if a multi-tenant result differs by account. At the same time, do not put an unbounded filter object directly in a key. Normalize the query and use a stable hash, then make the invalidation relation explicit.
Tags are attractive for derived pages, but only if the platform supports them as a bounded primitive. For Redis, maintain a reverse index only when its memory lifecycle is clear. Every membership must expire with the cached item, or a hot entity accumulates references forever. For large fan-out invalidations, versioning or a targeted asynchronous rebuild may be cheaper than synchronously deleting millions of keys.
Choosing a Strategy
Choose based on the consequence of stale data, write frequency, fan-out, and operational ownership, not on a slogan about cache invalidation being hard.
| Situation | Recommended combination | Why |
|---|---|---|
| Mostly-read product detail | Delete-on-write plus TTL | Simple, bounded staleness if delete fails |
| Static or editorial content | TTL plus CDN revalidation | Low write rate and acceptable age |
| Many derived pages per entity | Tags or dependency versioning plus TTL | A direct-key list is incomplete or too large |
| Multiple independent services | Transactional outbox plus idempotent consumers | Decouples cache owners from the writer |
| Financial or inventory decision | Do not rely on cache freshness | The authoritative transaction must enforce the invariant |
For a backend interview, say the freshness budget first, then pick a strategy. For example: “Product detail can be five seconds stale, but checkout cannot use a cached price. I would delete the product detail key after the database commit, keep a five-minute TTL as a backstop, and publish an outbox event for the CDN and search consumers. Checkout reads and validates the price transactionally.” That answer demonstrates both performance and correctness boundaries.
Operational Guardrails
An invalidation design is incomplete until it has a failure mode and signals. Track cache hit rate by key family, stale-read reports, invalidation attempts and failures, retry age, outbox backlog, consumer lag, TTL distribution, and origin load after a purge. A healthy aggregate hit rate can hide a broken invalidation path for one high-value resource.
Avoid broad FLUSHALL, KEYS, or pattern deletes in a shared production cache as a routine update mechanism. They turn one content change into a latency spike and can invalidate unrelated tenants. Prefer explicit keys, supported tag purge APIs, version increments, or rate-limited background work. During a cache outage, bounded timeouts and request coalescing protect the database from a miss storm; see cache-aside for the read-path fallback.
Test failures deliberately. Pause the event publisher, force a cache delete failure, send an event twice, restart a consumer, and verify that the system converges within the stated freshness budget. The goal is not a theoretical guarantee that caches never diverge. The goal is a known divergence window, observable repair path, and an authoritative path for decisions that cannot be stale.
Interview Questions
1. Why delete a cache entry instead of updating it after a database write?
Deleting is often safer because the read path already knows how to build the complete projection. A write endpoint may update only a price while the cached response includes availability, permissions, localization, and computed fields. After the authoritative commit, deleting forces a future cache-aside read to rebuild from the source. It still needs a retry path and TTL because a delete can fail or a late reader can repopulate an old value.
2. Does a TTL solve cache invalidation?
No. TTL provides an upper bound on how long an entry can remain cached after it was written, which is valuable when that bound matches the product’s freshness budget. It cannot react immediately to a revocation or price change and can create synchronized reloads if many keys expire together. Use it as the primary strategy only for data that can be stale for that duration, or as a backstop for explicit invalidation.
3. How do you avoid losing a cache invalidation event?
Write an outbox row in the same database transaction as the business change, then publish the row asynchronously with retries. This avoids the dual-write gap where a transaction commits but the process crashes before sending an event. Consumers must expect at-least-once delivery and make deletion or version advancement idempotent. Monitoring the outbox age and consumer lag is required because durability alone does not meet a freshness deadline.
4. What is the race condition in delete-after-write cache-aside?
A slow reader can fetch an old database value before a writer commits, then set that old value after the writer has deleted the key. The delete was correctly ordered but the late cache set recreates stale data. A short TTL reduces the window; a source version plus compare-and-set or versioned keys prevents an old writer from becoming the current representation. The right choice depends on how costly stale data is.
5. When should you avoid a cache for correctness-sensitive data?
Avoid making a cache the decision maker when an incorrect or stale answer creates an irreversible result, such as overselling inventory, authorizing a revoked user, or charging the wrong amount. The cache can still speed up display or provide a hint, but the final action must validate against an authoritative transactional boundary. This is a design distinction, not an argument against caching the surrounding read-heavy views.
Conclusion
Cache invalidation strategies are contracts about freshness, not just cache commands. Use TTL when bounded age is acceptable, delete-on-write for simple mutable objects, versioning for difficult races and high fan-out, and outbox-driven events when independent cache layers must converge. Keep authoritative transactions responsible for irreversible decisions, and make stale-data windows observable so a cache remains a performance tool rather than a hidden source of correctness bugs.
References
- Redis EXPIRE command
- Redis keyspace notifications
- RFC 9111: HTTP Caching
- Cloudflare cache purge documentation