Valkey’s cache invalidation isn’t about removing stale data; it’s about ensuring that when a piece of data changes, any cached copies of that data are recognized as stale and refreshed.

Let’s watch a typical scenario unfold. Imagine a simple e-commerce product catalog.

# Client requests product ID 123
GET /products/123
# Server fetches from DB, caches it, returns to client
{ "id": 123, "name": "Super Widget", "price": 19.99 }

# Later, price is updated in the database
UPDATE products SET price = 24.99 WHERE id = 123;

Now, if a client requests /products/123 again, they’ll still get the old price from the cache. This is the problem. How do we make sure the client gets the 24.99?

There are a few core strategies, each with its own trade-offs:

1. Time-To-Live (TTL) - The Simplest Approach

You set an expiration time for each cached item. After that time, Valkey automatically deletes it, forcing a fresh fetch from the source.

  • How it works: When you SET mykey "myvalue" EX 300 (5 minutes), Valkey internally stores a timestamp for when the key should expire. When GET mykey is called and the expiration time has passed, Valkey treats the key as if it doesn’t exist.
  • Diagnosis: Check the TTL of a key using TTL mykey. A positive number is seconds remaining, -1 means no expiration, and -2 means the key doesn’t exist.
  • Fix: Set a TTL when caching: SET product:123 '{"id":123,"name":"Super Widget","price":19.99}' EX 600 (10 minutes).
  • Why it works: It’s a passive, hands-off approach. No extra logic needed in your application to tell Valkey to invalidate. It just happens.
  • Downside: Data can be stale until the TTL expires. If updates are frequent, TTL might not be short enough, or if it’s too short, you lose cache benefits.

2. Explicit Invalidation - The "Delete It" Method

When the source data changes, you explicitly tell Valkey to remove the corresponding cache entry.

  • How it works: Your application, after updating the database, sends a DEL mykey command to Valkey. The next time the data is requested, it’s a cache miss, and the fresh data is fetched and re-cached.
  • Diagnosis: You can’t "diagnose" invalidation itself, but you can check if a key exists with EXISTS mykey. If it returns 0, the key is gone.
  • Fix: In your update logic:
    # Update DB
    db.execute("UPDATE products SET price = 24.99 WHERE id = 123")
    # Invalidate cache
    valkey_client.delete("product:123")
    
  • Why it works: It’s immediate. The moment the source data is updated, the cache is told to forget about it.
  • Downside: Requires careful application logic. If you forget to call DEL after an update, you have stale data. Also, if the same data is requested during the update-and-delete operation, you might get a cache miss when you didn’t intend to.

3. Write-Through Caching - The "Update Both" Strategy

Writes go to the cache and the database simultaneously (or in quick succession).

  • How it works: When data is updated, your application writes the new data to Valkey and the database. The cache entry is immediately updated with the fresh data.
  • Diagnosis: Check the value in Valkey (GET mykey) and compare it to the source of truth (your database).
  • Fix: In your update logic:
    new_price = 24.99
    product_id = 123
    # Update DB
    db.execute(f"UPDATE products SET price = {new_price} WHERE id = {product_id}")
    # Update Cache
    valkey_client.set(f"product:{product_id}", json.dumps({"id": product_id, "price": new_price}))
    
  • Why it works: The cache is always kept up-to-date with the latest writes. Reads will always hit the fresh data.
  • Downside: Increases write latency because you have to wait for both operations to complete. If the Valkey write fails but the DB write succeeds (or vice versa), you get inconsistency.

4. Cache-Aside (Lazy Loading) with Versioning/Timestamps

This is a common pattern. Reads check the cache. If it’s not there (or is stale), fetch from the DB, populate the cache, and return. The "stale" part is key here.

  • How it works: You store not just the data, but also a version number or a timestamp of the last modification. When retrieving data, you check the cache. If the cached item’s version/timestamp is older than the current version/timestamp (which you’d need to fetch or derive), you know it’s stale.
  • Diagnosis: You’d need to check both the cached data and the source for a version or timestamp. For example, HGETALL product:123 might return {"data": "...", "last_updated": "2023-10-27T10:00:00Z"}. You’d then query your DB for the product’s last_updated timestamp.
  • Fix:
    # When reading
    cached_data = valkey_client.hgetall("product:123")
    db_timestamp = db.query("SELECT last_updated FROM products WHERE id = 123")[0]
    
    if not cached_data or cached_data["last_updated"] < db_timestamp:
        # Fetch fresh data from DB
        fresh_product_data = db.query("SELECT * FROM products WHERE id = 123")[0]
        # Update cache with new data and timestamp
        valkey_client.hmset("product:123", {
            "data": json.dumps(fresh_product_data),
            "last_updated": str(db_timestamp) # Ensure it's a string for HSET
        })
        return fresh_product_data
    else:
        return json.loads(cached_data["data"])
    
  • Why it works: It combines the benefits of lazy loading (only cache what’s needed) with a mechanism to detect and correct staleness without explicit invalidation calls.
  • Downside: Requires more complex logic in your read path and a way to track versions/timestamps in your source data.

5. Event-Driven Invalidation (Pub/Sub)

Your backend services publish events when data changes. Valkey (or a service listening to Valkey) subscribes to these events and invalidates relevant cache entries.

  • How it works: A service that updates a product publishes a message like {"type": "product_updated", "id": 123} to a Valkey channel. A separate "cache invalidator" service subscribes to this channel using PSUBSCRIBE product_updates:*. When it receives the message, it issues a DEL product:123 command to Valkey.
  • Diagnosis: Monitor Valkey’s Pub/Sub traffic. Use PSUBSCRIBE __keyspace@0__:product:* to see keyspace notifications and PUBSUB CHANNELS to see active channels.
  • Fix:
    • Publisher Service: PUBLISH product_updates "{\"type\":\"product_updated\",\"id\":123}"
    • Invalidator Service (Valkey CLI): PSUBSCRIBE product_updates (and have a script/app listen and DEL keys).
  • Why it works: Decouples the data update logic from the cache invalidation logic, leading to cleaner services.
  • Downside: Adds complexity with message queues and separate services. Potential for message delivery issues.

The most surprising thing about cache invalidation is that a perfectly valid cache entry can be stale without Valkey having any idea. Valkey itself is a key-value store; it doesn’t inherently understand your application’s data model or when your database records change. It only knows about keys and their associated values and expiration times. The responsibility for knowing when data has changed and instructing Valkey to treat a key as invalid (or updating it) rests entirely with your application logic.

If you’ve implemented all the above and your cache is still returning stale data, the next thing you’ll likely hit is race conditions between read and write operations, where a read might happen just before an invalidation command is processed.

Want structured learning?

Take the full Valkey course →