Cache Invalidation Strategies: When TTL Fails

Phil Karlton said there are only two hard problems in computer science: cache invalidation and naming things. He was half joking. He should not have been.

Most teams ship with a TTL-based cache and call it done. Set the expiry to five minutes, ship it, and hope nobody notices when the cache serves stale data for 299 seconds. It works until it doesn’t. A user updates their profile, the UI refreshes instantly, but their old avatar still loads from cache for everyone else. Or worse: a price change in your billing system doesn’t propagate because Redis held the old value.

The problem is that TTL-based expiry is fire and forget. You set it and trust time to do the work. Time is a terrible cache manager. It doesn’t know when your data actually changed.

This post walks through the strategies that actually work in production: write-through invalidation, event-driven purging, hybrid approaches, and when to break the cache entirely. These are the patterns we use when the data corruption cost is higher than the latency cost of a cache miss.

TTL Is Not Invalidation

Let’s start with what TTL actually is: time-based expiry, not invalidation. TTL says “forget this key after N seconds.” It does not say “forget this key when the underlying data changes.”

TTL buys you time. It reduces the window of staleness, but it does not eliminate it. If you set a TTL of 60 seconds on a user profile cache, and the user updates their email address, everyone still sees the old email for up to 60 seconds. In a high-concurrency system, that’s 60 seconds of potential race conditions, incorrect decisions made on stale state, and customer-facing confusion.

The math is simple: assume your user profile is cached with a 60-second TTL. On a site with 10,000 concurrent users, roughly 166 requests per second are hitting that cache. If a user updates their profile, 9,960 of those requests in the next 59 seconds still serve the old data. That’s not a small window. That’s a design problem.

TTL works when the data is truly semi-durable—when slight staleness is acceptable. Dashboard aggregates? TTL is fine. Real-time billing state? TTL will cost you money. The mistake teams make is using the same strategy for both.

Real cache invalidation means: when the underlying data changes, the cache knows immediately and purges itself. No time delay. No hope. Certainty.

Write-Through Invalidation: The Gold Standard

Write-through invalidation is the pattern used in every production system that cannot afford staleness. The idea is simple: whenever you write to the database, you immediately invalidate the cache. The cache and the database are kept in sync by the write path, not by time.

Here’s the shape of it in pseudocode:

def update_user_email(user_id, new_email):
    # Write to the source of truth first
    db.execute(
        "UPDATE users SET email = ? WHERE id = ?",
        (new_email, user_id)
    )
    
    # Immediately invalidate the cache
    cache.delete(f"user:{user_id}")
    
    return user_id

The critical detail: write to the database first, then invalidate the cache. Never the other way around. If you delete the cache and then the database write fails, you have a cache miss but correct data. If you delete the cache after a failed write, you have a cache miss and the database still has the old value—the next read rebuilds the stale cache. Database-first is the safer order.

Write-through works well when:

  • Your write volume is manageable (you can afford the cache invalidation overhead).
  • Your writes are centralized (all mutations flow through a single service or handler).
  • The data is directly related to the cache key (not a transitive dependency).

The weakness emerges with transitive dependencies. If you cache “user:123:profile” and also cache “organization:456:members”, an update to the user’s organization membership invalidates both. But the user might also be cached in a list under “organization:456:active_users_sorted_by_join_date”. Now you need to invalidate that too. And the dashboard that shows “top organizations by member count.” And the analytics cache that feeds the admin panel. Write-through scales linearly with the number of derived caches that depend on a single piece of data. At some point, you’re invalidating more keys than you’re writing.

That’s when event-driven invalidation becomes necessary.

Event-Driven Invalidation: The Scalable Approach

Event-driven invalidation decouples the write from the cache purge. Instead of calling cache.delete() directly in your write handler, you emit an event. A separate consumer watches for those events and handles the invalidation asynchronously.

The shape of it looks like this:

def update_user_email(user_id, new_email):
    # Write to the database
    db.execute(
        "UPDATE users SET email = ? WHERE id = ?",
        (new_email, user_id)
    )
    
    # Emit an event (to Kafka, RabbitMQ, SQS, etc.)
    event_bus.publish("user.email.updated", {
        "user_id": user_id,
        "new_email": new_email,
        "timestamp": now()
    })
    
    return user_id

# Separate consumer, runs in its own process/pod
def on_user_email_updated(event):
    user_id = event["user_id"]
    
    # Invalidate all caches that depend on this user
    cache.delete(f"user:{user_id}")
    cache.delete(f"user:{user_id}:profile")
    cache.delete(f"user:{user_id}:preferences")
    # ... and any derived caches

The advantage is decoupling: your write handler doesn’t need to know about every cache that depends on the user. The consumer can be updated independently. You can also add multiple consumers—one for cache invalidation, one for analytics, one for audit logging. The same event feeds them all.

The tradeoff is latency. The cache is no longer invalidated synchronously. There’s a delay—a few milliseconds to a few hundred milliseconds depending on your event broker. During that window, reads still see the stale value. For user profiles or billing state, that window is usually acceptable. For real-time trading or medical records, it’s not.

Event-driven invalidation also requires that you enumerate all the caches that depend on a piece of data. A user email update might invalidate the user profile cache, the user preferences cache, the user’s organization’s member list, the dashboard’s top-users cache, and the analytics cache. If you miss one, that cache becomes a source of truth divergence. This is where cache tagging helps.

The pattern scales because you’re not checking every dependent cache inline. You’re publishing once and letting consumers handle their own invalidation strategy. If a new service needs to listen for user updates, it subscribes to the event. The original write handler doesn’t change.

Hybrid: Write-Through Plus Events

The most robust systems use both. Write-through for the primary cache (the one most frequently accessed), event-driven for secondary caches (the ones that are expensive to enumerate or have transitive dependencies).

Example: a user profile cache is invalidated immediately (write-through) because it’s accessed on every request and the staleness cost is high. But the “users in organization X” list is invalidated via event, because it depends on multiple user records and the staleness window is lower-risk.

def update_user_email(user_id, new_email, org_id):
    db.execute(
        "UPDATE users SET email = ? WHERE id = ?",
        (new_email, user_id)
    )
    
    # Write-through: invalidate the primary cache immediately
    cache.delete(f"user:{user_id}")
    
    # Event-driven: let consumers handle secondary caches
    event_bus.publish("user.updated", {
        "user_id": user_id,
        "org_id": org_id,
        "field": "email"
    })

This way, the user profile is always fresh (write-through), but the organization’s member list is invalidated asynchronously (event). The write handler is still lean—it only invalidates what it directly manages.

Cache Tags: Dependency Tracking Without Enumeration

Redis doesn’t have native cache tagging, but you can implement it with a simple pattern: store metadata about which caches depend on a piece of data, then use that metadata to invalidate all of them at once.

The pattern:

# When you create a cache entry, tag it
user_profile = fetch_user_profile(user_id)
cache.set(f"user:{user_id}", user_profile, ex=3600)
cache.sadd(f"tags:user:{user_id}", "user:profile")

user_org = fetch_user_org(user_id)
cache.set(f"user:{user_id}:org", user_org, ex=3600)
cache.sadd(f"tags:user:{user_id}", "user:org")

# When the user is updated, fetch all tagged caches and delete them
def invalidate_user(user_id):
    tags = cache.smembers(f"tags:user:{user_id}")
    for tag in tags:
        cache.delete(tag)
    cache.delete(f"tags:user:{user_id}")

This solves the enumeration problem. You don’t need to hardcode every cache that depends on a user. Each cache registers itself with a tag when created, and invalidation follows the tags. It’s a form of inverse dependency tracking.

The downside: you’re storing metadata about every cache entry, which adds memory overhead. A small price for not having to enumerate dependencies manually.

When to Skip Caching Entirely

Not every data access pattern needs a cache. Sometimes the database is fast enough, and caching adds complexity without benefit.

Consider these questions before you add a cache:

  • Is the database query actually slow? (Measure it. Use PostgreSQL query analysis or slow-query logs.)
  • Is the data read frequently enough to justify the invalidation complexity? (A cache hit rate under 70% is usually a sign you should not cache.)
  • Can you afford staleness? If not, the cache is write-through, which means you’re not saving much latency.
  • Is the data small enough to cache efficiently? Caching a 10MB blob per user is wasteful.

Many teams cache reflexively. “Let’s add Redis to make it faster.” But if your database query is a simple indexed lookup on PostgreSQL, it’s already sub-millisecond. The cache might add 2-5ms of round-trip latency. You’re trading sub-millisecond database latency for cache miss latency that’s often higher. Cache only when the underlying query is genuinely expensive—aggregations, joins across tables, full-text search.

When staleness is unacceptable and write-through invalidation is the only option, ask yourself: are you caching at all, or are you just adding latency to writes? If every write triggers an invalidation, the cache is only helping reads. If the read-to-write ratio is low (close to 1:1), the cache is not earning its complexity.

Production Patterns: Real Trade-Offs

Here’s how the major patterns play out in real systems:

Financial systems (billing, payments): Write-through invalidation only. No TTL on sensitive caches. A stale balance is a data corruption event. Stripe’s webhook handlers use this pattern—write to the database, invalidate immediately, and the next read is guaranteed fresh.

E-commerce (product catalogs, inventory): Hybrid approach. Product details (name, description) are cached with TTL (staleness window of 5-10 minutes is acceptable). Inventory levels are event-driven (when stock changes, an event triggers invalidation across all caches). This balances freshness and performance.

Social platforms (feeds, comments): Event-driven with eventual consistency. When a user posts, an event is published. Feed caches are invalidated asynchronously. Users might see a comment 100-500ms after it’s posted—acceptable for social media, unacceptable for financial systems.

Analytics and reporting: TTL only. Data is aggregated in batch jobs and cached for hours. Staleness is baked into the product. Nobody expects a real-time analytics dashboard to be updated the instant a transaction completes.

The pattern you choose depends on your data’s cost of staleness. High cost (financial, medical, legal): write-through. Medium cost (user-facing features, inventory): hybrid. Low cost (analytics, dashboards): TTL only.

Cache invalidation is hard because it forces you to think about data consistency. Most teams skip that thinking and ship TTL. It works until a customer notices they updated their password and still can’t log in, or a promotion didn’t apply because the price was cached. Those are the moments you wish you had thought about invalidation strategy upfront.

If you’re building a system where cache staleness could cause customer-visible problems or financial loss, it’s worth treating invalidation as a first-class design decision, not an afterthought. A bad caching strategy is silent—the system looks fast until the data corruption surfaces. That’s the kind of problem a senior engineer would catch in architecture review, and it’s worth fixing before you ship.