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 ofSET, instead of a separateEXPIREcall.KEEPTTL— preserve the key’s existing TTL instead of clearing it (only valid onSET).cursor,pattern,count— used withSCANto iterate keys safely;countis 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 freshSET) 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
GETagainst 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
KEYSagainst a production dataset of meaningful size; useSCANwithMATCHand a boundedCOUNTinstead. - Use
KEEPTTLwhen 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
UNLINKoverDELwhen 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:descriptionwith a 120-second TTL usingSET ... EX. Confirm the TTL withTTL, then simulate a content update by overwriting the value withKEEPTTLand confirm the TTL didn’t reset. - 2. Create three keys sharing the prefix
inventory:warehouseA:(e.g. for three different SKUs) usingMSET. UseSCAN 0 MATCH inventory:warehouseA:* COUNT 100to find them, then invalidate all three with a singleDELcall and confirm they’re gone withEXISTS. - 3. Implement a versioned cache: set
orders:versionto1, cache a value atorders:v1:summary, then bump the version withINCRand confirm that readingorders:v2:summarycomes 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
SETon an existing key clears its TTL; useKEEPTTLto preserve it when overwriting a value in place. TTLreturns-1for a key with no expiration and-2for a key that doesn’t exist;EXPIREreturns0if there was no key to set a TTL on.- Use
SCAN, neverKEYS, to find keys matching a pattern in production —KEYSblocks the whole server. - Use atomic commands like
GETDELinstead 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.
