Cache Invalidation Strategies

Cache invalidation is the process of making sure a cached copy of data doesn’t outlive its usefulness — removing or refreshing it the moment the underlying data changes, or letting it expire automatically after a set window. It matters because a cache that is never invalidated becomes a liar: it keeps serving fast, wrong answers long after the source of truth has moved on. Redis gives you several complementary tools for this — per-key TTLs, explicit deletion, atomic get-and-delete, and versioned key namespaces — and picking the right combination is one of the most consequential decisions in any caching layer.

Overview / How it works

In a typical cache-aside setup, an application reads from Redis first; on a miss it reads the real data source (a database, an API, a computation), then writes the result into Redis with a time-to-live (TTL). The hard part isn’t populating the cache — it’s knowing when a cached value has gone stale and getting rid of it before a client trusts it. Redis supports three complementary strategies, and production systems usually combine more than one:

TTL-based (lazy) invalidation. You attach an expiration to the key when you write it (SET key value EX seconds or a separate EXPIRE call). Redis guarantees the key disappears no later than that time. This is simple and self-healing — even if your application crashes or forgets to clean up, the key eventually vanishes — but it accepts a window of staleness up to the TTL length.

Explicit (write-through) invalidation. The moment the underlying data changes, the application immediately deletes (or overwrites) the corresponding cache key, typically right after the database write commits. The next read misses the cache, recomputes the value, and repopulates it. This gives you much tighter consistency than TTL alone, at the cost of needing your application code to know exactly which cache keys a given write affects.

Versioned keys (cache busting). Instead of tracking every individual key that needs deleting, you embed a version number in the key name itself, e.g. catalog:v1:electronics. A separate counter key (catalog:version) is incremented whenever the underlying dataset changes. Readers always build their cache key from the current version, so a version bump instantly makes every old-version key irrelevant without you having to enumerate or delete them one by one — they simply age out later via TTL. This is powerful for invalidating whole families of related keys at once.

What happens internally on expiration

Every key with a TTL has its absolute expiration timestamp stored in a separate internal hash table (the “expires” dictionary), apart from the main keyspace dictionary that holds the value itself. Redis uses two mechanisms together to actually remove expired keys:

Passive (lazy) expiration: whenever a client accesses a key, Redis first checks whether it has an expiration timestamp in the past. If so, the key is deleted on the spot and the command behaves as if the key never existed (a GET returns (nil)). This guarantees correctness for anything you actually touch, but a key nobody ever reads again would sit in memory forever if this were the only mechanism.

Active expiration: a background cycle runs roughly ten times per second. Each pass it samples a small batch of random keys that have a TTL set, deletes any that have expired, and if more than 25% of the sample was expired it immediately samples again. This reclaims memory from cold, never-accessed expired keys without requiring a full scan of the keyspace.

Because Redis executes commands one at a time on a single main thread, both expiration checks and the invalidation commands you issue (DEL, SET, EXPIRE, GETDEL) are atomic with respect to every other client. No other command can observe a half-deleted or half-expired key — this is exactly what makes Redis safe to use for cache invalidation without extra application-side locking.

One detail that trips people up constantly: a plain SET key value on a key that already has a TTL clears that TTL, turning the key permanent, unless you add the KEEPTTL option. This matters a lot for write-through invalidation where you overwrite a cached value in place instead of deleting it.

Syntax

Command Complexity Purpose
EXPIRE key seconds O(1) Set a TTL in seconds on an existing key
PEXPIRE key milliseconds O(1) Set a TTL in milliseconds
TTL key / PTTL key O(1) Inspect remaining TTL (seconds/ms)
PERSIST key O(1) Remove a key’s TTL, making it permanent again
DEL key [key ...] O(N) for N keys removed Immediate, synchronous deletion
UNLINK key [key ...] O(1) to unlink; memory freed asynchronously Non-blocking delete, safer for very large values
GETDEL key O(1) Atomically read a value and delete the key in one round trip
SCAN cursor MATCH pattern COUNT n O(1) per call, O(N) to fully iterate Non-blocking iteration over the keyspace
KEYS pattern O(N) Blocking full scan — avoid in production

General shape of the commands you’ll use most for invalidation:

EXPIRE  
SET   [EX  | PX  | KEEPTTL]
SCAN  MATCH  COUNT 
  • key — the cache key, conventionally colon-namespaced (e.g. user:42:profile).
  • seconds / milliseconds — how long the key should live from now.
  • EX / PX — set a TTL at write time as part of SET, instead of a separate EXPIRE call.
  • KEEPTTL — preserve the key’s existing TTL instead of clearing it (only valid on SET).
  • cursor, pattern, count — used with SCAN to iterate keys safely; count is a hint for how many keys to examine per call, not a hard limit on results returned.

Examples

1. TTL-based invalidation with KEEPTTL

SET product:1001:price "29.99" EX 60
TTL product:1001:price
GET product:1001:price
SET product:1001:price "24.99" KEEPTTL
TTL product:1001:price

Output:

OK
(integer) 60
"29.99"
OK
(integer) 60

The first SET writes the cached price with a 60-second TTL. When the underlying price changes, the second SET updates the value in place — and because it uses KEEPTTL, the countdown continues from where it was instead of resetting or being cleared. Without KEEPTTL, that second SET would silently remove the TTL and the key would become permanent.

2. Explicit invalidation on write (cache-aside)

SET user:42:profile "cached-profile-data"
EXPIRE user:42:profile 3600
GET user:42:profile
DEL user:42:profile
GET user:42:profile

Output:

OK
(integer) 1
"cached-profile-data"
(integer) 1
(nil)

Here the application populates the cache and gives it a generous one-hour safety-net TTL. When the user’s profile is updated in the primary database, the application immediately issues DEL against the cache key rather than waiting for the TTL to expire. The following GET misses ((nil)), so the next read will recompute the profile from the database and repopulate the cache with fresh data.

3. Versioned keys for bulk invalidation

SET catalog:version 1
SET catalog:v1:electronics "cached-listing-data"
GET catalog:v1:electronics
INCR catalog:version
EXISTS catalog:v2:electronics
GET catalog:v1:electronics

Output:

OK
OK
"cached-listing-data"
(integer) 2
(integer) 0
"cached-listing-data"

Instead of deleting individual listing keys when the electronics catalog changes, the application increments a shared catalog:version counter. Every reader builds its cache key from the current version, so future reads immediately ask for catalog:v2:electronics — which doesn’t exist yet (EXISTS returns 0) and gets computed fresh. The old catalog:v1:electronics key is still sitting in memory, unreachable by normal traffic; this is why versioned keys should still carry a TTL, so orphaned old versions eventually get reclaimed instead of accumulating forever.

How it works step by step

For a typical write-through invalidation flow (Example 2 above):

  • 1. The application writes the new value to the primary data store and waits for that write to commit successfully.
  • 2. Only after the commit succeeds, the application issues DEL (or a fresh SET) against the corresponding Redis key. Deleting after the database commit, not before, avoids a window where a concurrent reader could repopulate the cache with the stale pre-write value.
  • 3. The next GET against that key returns (nil) because Redis’s single-threaded execution guarantees the deletion was applied atomically — no reader can observe a partially-deleted key.
  • 4. The application reads the fresh value from the primary store and writes it back into Redis with an appropriate TTL, and the cache is warm again.

For TTL-based expiration (Example 1), Redis checks the key’s stored expiration timestamp against the current time either passively (on the next access) or actively (via the background sampling cycle described earlier) — whichever happens first physically removes the key from the keyspace and frees its memory.

Common Mistakes

Mistake 1: Using KEYS to find keys to invalidate

MSET session:abc123 "active" session:def456 "active" session:ghi789 "active"
KEYS session:*

Output:

OK
1) "session:abc123"
2) "session:def456"
3) "session:ghi789"

(The exact order returned isn’t guaranteed.) On a handful of test keys this looks harmless, but KEYS is O(N) against the entire keyspace and, because Redis is single-threaded, it blocks every other command until it finishes. On a production instance with millions of keys, a single KEYS session:* call can freeze the whole server for seconds. Always use SCAN cursor MATCH pattern COUNT n instead — it walks the keyspace incrementally across multiple calls without blocking other clients, at the cost of needing a small loop in your application to follow the returned cursor until it comes back as 0.

Mistake 2: Forgetting to set a TTL at all

SET cache:homepage:html "full-page-markup"
TTL cache:homepage:html

Output:

OK
(integer) -1

A TTL of -1 means the key exists but has no expiration — it will live in memory forever unless something explicitly deletes it. If the application that’s supposed to invalidate this key on write ever has a bug, gets removed, or simply misses an edge case, this becomes a permanent memory leak and a permanently stale value. Even when you’re relying primarily on explicit invalidation, it’s good practice to attach a generous “safety net” TTL so a missed invalidation self-heals eventually instead of lasting forever.

Mistake 3: A GET-then-DEL race instead of an atomic operation

SET cache:report:daily "stale-report-data"
GETDEL cache:report:daily
GET cache:report:daily

Output:

OK
"stale-report-data"
(nil)

A common anti-pattern is to run GET to read a value, then a separate DEL to invalidate it, as two round trips. Between those two calls, another client could write a new value that your DEL then incorrectly wipes out. GETDEL performs the read and the delete as a single atomic command, so there’s no window for another client to interleave — use it whenever your logic is “read this value once, then consider it consumed and invalidated.”

Mistake 4: Assuming EXPIRE does something on a key that isn’t there

DEL cache:temp:report
EXPIRE cache:temp:report 60

Output:

(integer) 0
(integer) 0

EXPIRE returns (integer) 1 if it successfully set a TTL on an existing key, and (integer) 0 if the key didn’t exist (nothing happened). It’s easy to write invalidation logic that calls EXPIRE without checking this return value and silently assume the key now has a deadline, when in fact there was nothing to expire at all. Always check the return value, or better, set the TTL atomically at write time with SET key value EX seconds so there’s no separate step that can silently no-op.

Best Practices

  • Combine strategies: use explicit DEL/overwrite on write for freshness, and always keep a TTL as a safety net in case an invalidation is ever missed.
  • Invalidate (or delete) the cache key after the primary data store’s write has committed, never before, to avoid a stale value being cached by a concurrent reader.
  • Never use KEYS against a production dataset of meaningful size; use SCAN with MATCH and a bounded COUNT instead.
  • Use KEEPTTL when overwriting a cached value in place if you don’t want the TTL clock to reset or disappear.
  • Prefer versioned key namespaces when a single logical change should invalidate many related keys at once — it avoids having to enumerate every affected key.
  • Use GETDEL (or other atomic commands) instead of separate GET-then-DEL round trips whenever a value should be consumed and invalidated together.
  • Add small random jitter to TTLs on related keys so they don’t all expire in the same instant and cause a “thundering herd” of simultaneous cache-miss recomputes.
  • Prefer UNLINK over DEL when invalidating keys that hold very large values, so memory reclamation happens asynchronously instead of blocking.
  • Monitor expired-key and eviction metrics in production so silently-growing TTL-less keys get caught early.

Practice Exercises

  • 1. Cache a product description under product:2002:description with a 120-second TTL using SET ... EX. Confirm the TTL with TTL, then simulate a content update by overwriting the value with KEEPTTL and confirm the TTL didn’t reset.
  • 2. Create three keys sharing the prefix inventory:warehouseA: (e.g. for three different SKUs) using MSET. Use SCAN 0 MATCH inventory:warehouseA:* COUNT 100 to find them, then invalidate all three with a single DEL call and confirm they’re gone with EXISTS.
  • 3. Implement a versioned cache: set orders:version to 1, cache a value at orders:v1:summary, then bump the version with INCR and confirm that reading orders:v2:summary comes back empty, meaning it needs to be recomputed and re-cached.

Summary

  • Redis supports three complementary invalidation strategies: TTL-based expiration, explicit deletion/overwrite on write, and versioned key namespaces.
  • Expiration happens via two mechanisms — lazy checks on access and an active background sampling cycle — both of which run atomically because Redis is single-threaded.
  • A plain SET on an existing key clears its TTL; use KEEPTTL to preserve it when overwriting a value in place.
  • TTL returns -1 for a key with no expiration and -2 for a key that doesn’t exist; EXPIRE returns 0 if there was no key to set a TTL on.
  • Use SCAN, never KEYS, to find keys matching a pattern in production — KEYS blocks the whole server.
  • Use atomic commands like GETDEL instead of separate GET-then-DEL calls to avoid race conditions.
  • Always keep a TTL as a safety net, even when relying primarily on explicit invalidation, to avoid permanent memory leaks from missed invalidations.