Choosing TTLs and Avoiding Thundering Herds

Every key you store in Redis can carry an optional expiration — a time-to-live (TTL) after which Redis deletes it automatically. Expiration is the mechanism behind caches, sessions, rate limiters, and short-lived locks, and getting TTLs right is one of the most consequential decisions you’ll make when using Redis as a cache. Set TTLs too short and you thrash your backend with constant cache misses; give a huge batch of keys the exact same TTL and you risk a thundering herd, where thousands of keys expire in the same instant and every client stampedes your database at once. This lesson covers how expiration actually works inside Redis, how to set and inspect TTLs correctly, and the concrete patterns — jitter, locking, and early refresh — that keep a cache-miss storm from taking down whatever sits behind your cache.

Overview: How TTL Works in Redis

Internally, a Redis database is really two hash tables layered on top of each other: the main keyspace dictionary that maps every key to its value, and a separate expires dictionary that maps a subset of those keys to an absolute deadline, stored in milliseconds since the Unix epoch. A key with no TTL simply has no entry in the expires dictionary at all. When you run EXPIRE, PEXPIRE, or SET key value EX seconds, Redis computes that absolute deadline and adds (or overwrites) the key’s entry in the expires table — it never touches the stored value itself, just the bookkeeping around when the value should disappear.

Because Redis is single-threaded, every command — including the expiration bookkeeping — runs to completion before the next command starts. That is why setting a value and its TTL together with SET key value EX seconds is safe under concurrent access: no other client can ever observe the key with the old value, or with the value but no TTL, in between.

A plain SET on a key that already has a TTL clears that TTL, because SET replaces the whole key including its expiration metadata by default. If you want to update a value without disturbing its existing expiration window, you must explicitly say so with the KEEPTTL option. This single behavior trips up more Redis caching code than almost anything else, so it gets its own worked example below.

Redis caches are popular precisely because reads and writes are fast and expiration is automatic, but that automation has a dark side at scale: if you don’t think about when keys expire relative to each other, you can accidentally synchronize thousands of cache misses into the same millisecond.

Syntax

The core commands for setting and inspecting expiration:

Command Purpose Time Complexity
SET key value [EX|PX|EXAT|PXAT ...|KEEPTTL] [NX|XX] Set a value, optionally with an expiration or lock semantics O(1)
EXPIRE key seconds [NX|XX|GT|LT] Set a TTL (relative, in seconds) on an existing key O(1)
PEXPIRE key milliseconds [NX|XX|GT|LT] Same as EXPIRE but in milliseconds O(1)
EXPIREAT key unix-seconds Set an absolute expiration timestamp O(1)
TTL key Seconds remaining until expiration O(1)
PTTL key Milliseconds remaining until expiration O(1)
PERSIST key Remove a key’s TTL, making it permanent O(1)
EXPIRETIME key Absolute Unix timestamp (seconds) a key will expire at (Redis 7.0+) O(1)

Notes on the flags:

  • EX seconds / PX milliseconds — set a TTL relative to now.
  • EXAT / PXAT — set an absolute expiration timestamp instead of a relative one.
  • KEEPTTL — on SET, preserve whatever TTL the key already had instead of clearing it.
  • NX on SET — only set if the key does not already exist. This is the basis of the locking pattern used against thundering herds.
  • XX on SET — only set if the key already exists.
  • NX / XX / GT / LT on EXPIRE — only apply the new TTL if the key has no TTL, already has one, the new TTL is greater than the current one, or the new TTL is less than the current one, respectively.

General shape of the commands:

SET key value [EX seconds | PX milliseconds | EXAT unix-time-seconds | PXAT unix-time-milliseconds | KEEPTTL] [NX | XX]
EXPIRE key seconds [NX | XX | GT | LT]
PEXPIRE key milliseconds [NX | XX | GT | LT]
TTL key
PTTL key
PERSIST key
EXPIRETIME key

Examples

Example 1: Setting and inspecting a TTL

A typical cached session, set to expire in one hour:

SET session:abc123 "user:42:cart-data" EX 3600
TTL session:abc123
GET session:abc123

Output:

OK
(integer) 3600
"user:42:cart-data"

SET ... EX 3600 creates the key and its expires-table entry in one atomic step. TTL reports the seconds remaining (it will tick down as real time passes), and GET confirms the value is readable like any other string until that deadline arrives.

Example 2: KEEPTTL versus a plain SET

This is the mistake that catches almost everyone at least once: updating a cached value with a plain SET silently wipes out its TTL.

SET cache:product:100 "widget-data-v1" EX 60
TTL cache:product:100
SET cache:product:100 "widget-data-v2" KEEPTTL
TTL cache:product:100
SET cache:product:100 "widget-data-v3"
TTL cache:product:100

Output:

OK
(integer) 60
OK
(integer) 60
OK
(integer) -1

The first SET ... EX 60 gives the key a 60-second life. Updating it with KEEPTTL changes the value but leaves the countdown untouched (you may see 59 instead of 60 depending on exact timing — it’s still counting down in real time). The final plain SET, with no KEEPTTL, resets the key to have no expiration at all, and TTL reports -1, meaning "this key exists but never expires."

Example 3: A recompute lock with SET NX EX

One of the most effective ways to stop a thundering herd is to let only one client recompute an expensive cached value at a time. SET key value NX EX ttl doubles as an atomic, self-expiring lock:

SET lock:product:100 "1" NX EX 10
SET lock:product:100 "1" NX EX 10
GET lock:product:100
TTL lock:product:100

Output:

OK
(nil)
"1"
(integer) 10

The first client’s SET ... NX succeeds and returns OK because lock:product:100 didn’t exist yet — it now holds the lock and is responsible for recomputing cache:product:100. A second client racing to do the same thing gets (nil): the key already exists, so NX refuses to overwrite it. That second client should back off and either wait briefly or serve the still-cached (if slightly stale) value, instead of also hammering the database. The lock expires on its own after 10 seconds via EX, so a crashed worker can never hold it forever.

How It Works Step by Step: Lazy and Active Expiration

Redis never scans the entire keyspace looking for expired keys on every tick — on a database with millions of keys that would be far too expensive for a single-threaded server to do frequently. Instead it combines two complementary mechanisms:

  • Lazy expiration. Whenever a key is accessed by any command — GET, EXISTS, a write, anything — Redis first checks that key’s entry in the expires dictionary. If the deadline has passed, Redis deletes the key on the spot and behaves exactly as if the key had never existed (returning nil, 0, or an empty result as appropriate). This guarantees you never read stale data past its TTL, even if the background sweep hasn’t gotten to that key yet.
  • Active expiration. A background cycle runs by default about 10 times per second. Each pass randomly samples a batch of keys from the expires dictionary, deletes any that have passed their deadline, and — if more than a quarter of that sample was expired — immediately samples another batch rather than waiting for the next tick. This lets Redis burn through a large pile of simultaneously-expiring keys quickly, and it’s also how memory gets reclaimed for keys nobody ever reads again after they expire.

Replicas add one more subtlety: a replica does not independently decide a key has expired and delete it on its own initiative. It waits for the master to send an explicit DEL once the master lazily or actively expires that key, keeping replica and master state consistent. In the meantime, a replica will still logically hide an expired key from read queries by checking the deadline itself, even though the key technically hasn’t been physically deleted from its own memory yet.

Avoiding Thundering Herds (Cache Stampedes)

A thundering herd (also called a cache stampede or dogpile) happens when a large number of cache keys expire at effectively the same moment. The next request for each of those keys misses the cache, and if your traffic is high enough, all of those misses hit your database or origin service within the same second — exactly the load spike a cache exists to prevent. The classic way to cause this by accident is priming a cache in bulk with one fixed TTL:

SET cache:product:700 "widget-a" EX 3600
SET cache:product:701 "widget-b" EX 3612
SET cache:product:702 "widget-c" EX 3588
TTL cache:product:700
TTL cache:product:701
TTL cache:product:702

Output:

OK
OK
OK
(integer) 3600
(integer) 3612
(integer) 3588

If every one of ten thousand product keys were primed with exactly EX 3600, they would all expire within the same second an hour later, and every visitor after that instant would miss the cache simultaneously. Adding a small random offset to each TTL — here 3612 and 3588 instead of a flat 3600 — spreads those expirations across a window instead of a single instant. A common approach is base TTL plus or minus 5–10% chosen randomly by your application before each SET.

Three techniques combine well against thundering herds:

  • Jitter. Randomize each key’s TTL slightly, as above, so a batch of keys set together doesn’t expire together.
  • Single-flight locking. Use the SET key value NX EX ttl pattern from Example 3 so only one client recomputes an expensive value at a time; everyone else serves the existing (possibly slightly stale) cached value or waits a moment and retries.
  • Early / probabilistic refresh. Instead of waiting for a hard expiration, have clients check the remaining TTL and refresh the cache before it actually expires, with a probability that increases as the deadline approaches. This spreads recomputation work out over time rather than concentrating it at the exact moment of expiry, and readers never see a cache miss at all.

Common Mistakes

Mistake: using KEYS to find cache keys in production. KEYS pattern is O(N) and, because Redis is single-threaded, it blocks every other command on the server for as long as the scan takes — on a database with millions of keys that can mean seconds of total unavailability.

SET cache:product:1 "a"
SET cache:product:2 "b"
SET cache:product:3 "c"
KEYS cache:*
SCAN 0 MATCH cache:* COUNT 100

Output:

OK
OK
OK
1) "cache:product:1"
2) "cache:product:2"
3) "cache:product:3"
1) "0"
2) 1) "cache:product:1"
   2) "cache:product:2"
   3) "cache:product:3"

(Order isn’t guaranteed for either command.) SCAN does the same job cursor by cursor, returning a small slice per call and a cursor to continue with, without ever blocking the server for more than the time it takes to process one small batch. Always reach for SCAN over KEYS outside of one-off debugging on a tiny dataset.

Mistake: forgetting to set a TTL on cache entries at all. A key set without EX/PX lives forever unless something explicitly deletes it.

SET cache:report:daily "report-payload"
TTL cache:report:daily

Output:

OK
(integer) -1

-1 means the key exists but has no expiration. If this was meant to be a cache entry, it will now sit in memory forever (or until it’s evicted under a maxmemory policy, which is a much blunter and less predictable way to reclaim it). The fix is simply to always attach EX/PX when writing anything you intend as a cache entry, e.g. SET cache:report:daily "report-payload" EX 86400.

Mistake: assuming EXPIRE on a key that doesn’t exist does something.

EXPIRE cache:does-not-exist 60
SET cache:product:600 "widget"
EXPIRE cache:product:600 60
TTL cache:product:600

Output:

(integer) 0
OK
(integer) 1
(integer) 60

EXPIRE on a nonexistent key returns (integer) 0 and does nothing — it does not create the key or error out, so code that ignores the return value can silently believe it set a TTL that never took effect. Always check for 1 (success) versus 0 (key didn’t exist) when the order of your SET/EXPIRE calls isn’t guaranteed.

Mistake: a non-atomic "check, then set" race under concurrent cache misses.

GET cache:product:500
SET cache:product:500 "expensive-computed-value" EX 300

Output:

(nil)
OK

Run by a single client this looks harmless, but under real concurrency many clients can all run GET, all see (nil) at nearly the same instant, and all independently go recompute and write the same expensive value — the exact thundering-herd behavior this lesson is about, just triggered by a cold key instead of a mass expiration. The fix is the atomic lock pattern from Example 3: use SET lock:key "1" NX EX ttl so only the first client to arrive does the expensive work, while the rest wait or serve stale data.

Best Practices

  • Always attach a TTL to cache entries; never rely on remembering to clean them up manually later.
  • Choose TTLs based on how stale the data is allowed to be for your use case, not an arbitrary round number.
  • Add random jitter (roughly 5–10%) to TTLs whenever you prime many keys at once, so they don’t all expire in the same instant.
  • Use SET key value NX EX ttl as a short-lived lock so only one process recomputes an expensive value at a time.
  • Prefer SCAN over KEYS for any pattern-matching against a production keyspace.
  • Use KEEPTTL when refreshing a cached value in place if you want to preserve its original expiration window, and a plain SET ... EX when you want to reset the clock.
  • Check the return value of EXPIRE (1 or 0) instead of assuming it always succeeded.
  • Don’t rely on RDB or AOF persistence to save you from a missing TTL — a key that should never have existed past its intended lifetime will still be restored on reload if you forgot to set the expiration in the first place.

Practice Exercises

  • You cache product pages under cache:product:id for 30 minutes each. During a flash sale you prime 50,000 product keys in a tight loop. Rewrite the TTL you pass to each SET so that all 50,000 keys don’t expire within the same second half an hour later. (Hint: base TTL plus a small random offset per key.)
  • Design a lock key pattern so that cache:homepage is recomputed at most once every 5 seconds under very high concurrency, using SET ... NX EX. Work out what should happen to the lock key once the recompute finishes — does it need to be deleted, or is letting it expire on its own sufficient?
  • A session key session:xyz has a 2-hour TTL and must be renewed on every request without ever losing the session mid-use. Decide whether to use EXPIRE session:xyz 7200, SET session:xyz value KEEPTTL, or a plain SET session:xyz value EX 7200 on each request, and explain what each choice does to the expiration window.

Summary

  • A TTL is stored in a separate expires dictionary keyed by an absolute deadline; setting one never touches the value itself.
  • A plain SET on an existing key clears its TTL unless you add KEEPTTL.
  • TTL/PTTL return -1 for "no TTL set" and -2 for "key doesn’t exist"; EXPIRE on a missing key returns 0 and does nothing.
  • Redis combines lazy expiration (checked on access) with an active background cycle (sampling keys ~10 times per second) so memory is reclaimed even for keys nobody reads again.
  • A thundering herd happens when many keys expire together and every resulting cache miss hits your backend at once; jitter, single-flight locks via SET NX EX, and early probabilistic refresh all reduce that spike.
  • Use SCAN, never KEYS, for any pattern-matching against a live production keyspace.