Skip to content
ADevGuide Logo ADevGuide
Go back

Cache Invalidation Strategies: TTL, Events and Versioned Keys

By Pratik Bhuite | 27 min read

Hub: Java / Interview Fundamentals

Series: Backend Interview Mastery

Last verified: Sep 10, 2026

Part 28 of 32 in the Backend Interview Mastery

Key Takeaways

On this page
Reading Comfort:

A source database publishing a change event to refresh application, Redis, and edge caches

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

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 operationExample freshness budgetSafer default
Editorial pageMinutes or hoursTTL plus CDN revalidation
Product detailsSeconds to a few minutesDelete-on-write plus TTL backstop
Dashboard aggregateSecondsScheduled or event-driven refresh
Authorization or entitlementNear zero for revocationAuthoritative check or immediate event invalidation
Inventory reservation or paymentNo cache decision for the invariantTransactional 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:

  1. Reader A misses product:42 and reads the old database value.
  2. Writer B commits a new price and deletes product:42.
  3. 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:

TechniqueWhat it preventsCost and limitation
Short TTLLimits lifetime of a stale reinsertMore source reads and still stale within the TTL
Versioned keyMakes old writes target an old versionRequires version lookup and cleanup by TTL
Compare-and-setRejects a write that has an older versionNeeds cache support and a reliable version field
Update event consumerDeletes stale copies after the eventDelivery lag and outbox complexity remain
Per-key request coalescingReduces duplicate rebuilds on a missDoes 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:

RepresentationOwnerInvalidation action
product:42Product APIDelete after commit
category:shoes:page:1Catalog APIDelete or bump category version if ordering changes
/products/42CDNPurge exact URL or tag
Search documentSearch indexerConsume 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.

SituationRecommended combinationWhy
Mostly-read product detailDelete-on-write plus TTLSimple, bounded staleness if delete fails
Static or editorial contentTTL plus CDN revalidationLow write rate and acceptable age
Many derived pages per entityTags or dependency versioning plus TTLA direct-key list is incomplete or too large
Multiple independent servicesTransactional outbox plus idempotent consumersDecouples cache owners from the writer
Financial or inventory decisionDo not rely on cache freshnessThe 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

  1. Redis EXPIRE command
  2. Redis keyspace notifications
  3. RFC 9111: HTTP Caching
  4. Cloudflare cache purge documentation

YouTube Videos

  1. Caching, Cache Invalidation and Eviction
  2. Cache, Cache Patterns, Cache Invalidation and Cache Eviction

Share this post on:

Next in Series

Continue through the [object Object] with the next recommended article.

Related Posts

Keep Learning with New Posts

Subscribe through RSS and follow the project to get new series updates.

Was this guide helpful?

Share detailed feedback

Previous Post
Cache Stampede: Avalanche, Penetration and Prevention
Next Post
Redis Architecture: Data Types, Persistence and Scaling