from EXPIRE to TTL jitter against thundering herd
Redis TTL values do not just determine when a key disappears, they directly influence system load at expiration time. Anyone who sets thousands of keys with an identical TTL risks a thundering herd effect, where all caches empty out at the same moment and flood the backend with requests. TTL jitter solves exactly this problem.
Table of Contents
- 1. What Redis TTL strategies really accomplish
- 2. EXPIRE, TTL and PERSIST at a glance
- 3. Passive expiration: expiry on access
- 4. Active expiration: the background cycle
- 5. How both mechanisms work together
- 6. The thundering herd problem with synchronous TTLs
- 7. Implementing TTL jitter in practice
- 8. Further TTL strategies: refresh-ahead and soft TTL
- 9. TTL strategies compared
- 10. Summary
- 11. FAQ
1. What Redis TTL strategies really accomplish
A Redis TTL strategy is far more than simply setting an expiration time on a key. TTL, short for time to live, defines the lifespan of a key in seconds or milliseconds, after which Redis automatically removes it. At first glance this looks trivial, but the concrete choice of TTL values and the distribution of these values across many keys directly affects system stability, especially in cache architectures under high load.
In practice, the difference between a naive and a well thought out TTL strategy usually only shows up under load: a naive approach sets the same fixed TTL for all cache entries of a type, say one hour. That works unnoticed for a long time, until the moment arrives when many keys expire at once and all affected requests bypass the cache and hit the backend simultaneously. A well thought out Redis TTL strategy deliberately spreads expiration times to avoid exactly this effect.
The following sections cover both the basics of EXPIRE, TTL and PERSIST and the internal mechanisms of active and passive expiration, before introducing concrete TTL strategies such as jitter, refresh-ahead and soft TTL that keep production Redis environments running with stable load profiles.
2. EXPIRE, TTL and PERSIST at a glance
EXPIRE key seconds sets a relative expiration time in seconds on an existing key, PEXPIRE works analogously with milliseconds for finer precision. Since Redis 7.0, both commands support additional options such as NX, XX, GT and LT, which allow expressing conditional TTL changes, for example GT to only set a TTL if it is longer than the current one. This is especially useful when several processes might update the same key with different TTL requirements.
TTL key returns the remaining lifespan in seconds, minus 1 signals the key exists but has no TTL set, minus 2 means the key does not exist at all. PERSIST key removes a set TTL entirely and makes the key permanent again, without changing the value itself. This command matters for cases where a temporary cache entry should later be promoted to a permanent record, for example when a shopping cart turns into an order.
# Set a relative TTL of 3600 seconds on an existing key
redis-cli> SET session:abc123 "user-data" EX 3600
OK
redis-cli> TTL session:abc123
(integer) 3600
# Conditional TTL update since Redis 7.0: only extend, never shorten
redis-cli> EXPIRE session:abc123 7200 GT
(integer) 1
redis-cli> TTL session:abc123
(integer) 7200
# Attempting a shorter TTL with GT is rejected
redis-cli> EXPIRE session:abc123 1800 GT
(integer) 0
# PERSIST removes the TTL entirely, the key becomes permanent
redis-cli> PERSIST session:abc123
(integer) 1
redis-cli> TTL session:abc123
(integer) -1
3. Passive expiration: expiry on access
Redis implements two complementary mechanisms to actually remove expired keys, and passive expiration is the simpler of the two. Whenever a client accesses a key with GET, EXISTS or another read command, Redis checks before actual processing whether the set TTL has already expired. If so, the key is deleted immediately and the client receives a response as if the key had never existed, such as nil for GET.
The decisive point about passive expiration: without an actual access, an expired key would theoretically remain in memory indefinitely, because Redis never actively touches it. For rarely read keys with a very short TTL, that would mean expired but unused data unnecessarily occupying memory. This is exactly the gap that the second mechanism, active expiration, closes.
4. Active expiration: the background cycle
Active expiration runs as a periodic background process, executed ten times per second by default, configurable via the hz parameter in the Redis configuration. On every pass, Redis selects a random sample of keys with a set TTL from the internal expire table, checks how many of them have already expired, and immediately deletes the expired keys. If the share of expired keys in the sample is above 25 percent, Redis repeats the pass immediately instead of waiting for the next cycle.
This adaptive mechanism ensures that Redis speeds up cleanup work when many keys expire at once, without blocking the main process by iterating over all keys completely. The sample size is deliberately kept small, 20 keys per database and pass by default, to avoid noticeably affecting the latency of the single threaded event loop. This is a classic trade-off between immediate memory reclamation and response time stability.
# Inspect the active expiration cycle frequency (default 10 Hz)
redis-cli> CONFIG GET hz
1) "hz"
2) "10"
# Set many keys with a short TTL to observe active expiration at work
redis-cli> MSET temp:1 a temp:2 b temp:3 c
OK
redis-cli> EXPIRE temp:1 1
(integer) 1
redis-cli> EXPIRE temp:2 1
(integer) 1
redis-cli> EXPIRE temp:3 1
(integer) 1
# After ~1 second, the background cycle removes them without any GET
redis-cli> DBSIZE
(integer) 3
# ... wait 1-2 seconds ...
redis-cli> DBSIZE
(integer) 0
5. How both mechanisms work together
Passive and active expiration complement each other rather than replacing one another. Passive expiration guarantees that a client never gets back a logically expired value, even if the background cycle has not yet reached the key in question. Active expiration, in turn, ensures that memory used by unused, expired keys is reclaimed promptly, without having to wait for a random read access.
For replication setups, an important special rule applies: only the master actually performs active and passive expiration and generates an explicit DEL command on every deletion, which is propagated to all replicas. A replica never deletes expired keys on its own, it waits for this DEL command from the master. This prevents inconsistencies between master and replicas that would arise if both sides independently decided on the expiration moment.
6. The thundering herd problem with synchronous TTLs
The thundering herd problem arises when a large number of cache entries with identical or very similar TTLs expire at the same time. A typical scenario: a nightly batch job populates tens of thousands of product data keys in the cache, all with a fixed TTL of exactly one hour. After exactly one hour, all these keys expire nearly simultaneously, and the next wave of incoming requests no longer hits the cache but goes directly to the database or backend system.
The result is a sudden, massive load spike on the backend that, in the worst case, causes timeouts, connection pool exhaustion or a complete outage, while the cache simultaneously tries to reload all affected values in parallel. This self-reinforcing effect is particularly insidious because it stays invisible during normal operation and only becomes visible at sufficiently high load or with a sufficiently large number of simultaneously set keys, often only weeks after the initial deployment.
7. Implementing TTL jitter in practice
TTL jitter solves the thundering herd problem by adding a small random offset to the base TTL, so that keys that would otherwise expire at the same time instead expire spread out over a time window. Instead of a fixed TTL of 3600 seconds for all keys, you set, for example, a TTL between 3600 and 3900 seconds, chosen randomly per key. With ten thousand keys, expiration then spreads over a five minute window instead of a single moment, which brings backend load down to a manageable level.
The size of the jitter window depends on the concrete use case: a jitter of 5 to 10 percent of the base TTL is a common starting point that offers a good compromise between data freshness and load distribution for most cache workloads. It is important to compute the jitter when writing the key, not to set it globally the same for all keys of a batch, otherwise a synchronous group forms again, just shifted, but still shared.
# Without jitter: all keys expire at exactly the same moment
redis-cli> SET product:1001 "..." EX 3600
redis-cli> SET product:1002 "..." EX 3600
redis-cli> SET product:1003 "..." EX 3600
# With jitter: base TTL plus a random offset per key (application side)
# base_ttl = 3600, jitter = random(0, 300) computed per key before SET
redis-cli> SET product:1001 "..." EX 3742
redis-cli> SET product:1002 "..." EX 3618
redis-cli> SET product:1003 "..." EX 3891
-- Server-side jitter inside a Lua script keeps jitter logic close to the write path
-- redis-cli EVAL "$(cat set_with_jitter.lua)" 1 product:1001 "..." 3600 300
local base_ttl = tonumber(ARGV[2])
local jitter_max = tonumber(ARGV[3])
local jitter = math.random(0, jitter_max)
redis.call("SET", KEYS[1], ARGV[1], "EX", base_ttl + jitter)
return base_ttl + jitter
8. Further TTL strategies: refresh-ahead and soft TTL
Besides TTL jitter, there are further strategies targeting related problems. Refresh-ahead proactively renews a cache entry before it actually expires, typically when the remaining TTL falls below a threshold such as 20 percent of the original TTL. To do this, the remaining TTL is checked with TTL key on every read access, and if the threshold is undershot, the value is recomputed asynchronously and written back with a fresh TTL, while the current client still receives the still valid old value.
Soft TTL separates the technical Redis TTL from a logical expiration timestamp stored inside the value itself. The cache entry additionally carries a timestamp from which it is considered stale, while the technical Redis TTL is set considerably longer. On access to a logically stale but technically still present value, the application can decide to serve the old value briefly anyway, while a refresh is triggered in the background, a pattern known as stale-while-revalidate. This prevents a cache miss under load from ever forcing a direct, synchronous backend request.
9. TTL strategies compared
The choice of the right TTL strategy depends on the load profile and the criticality of data freshness. The following table contrasts the most important approaches.
| Strategy | Solves | Implementation effort | Recommendation |
|---|---|---|---|
| Fixed TTL without jitter | Nothing, prone to thundering herd | Very low | Only for small, uncritical data volumes |
| TTL jitter | Synchronous mass expirations | Low | Standard solution for batch populated caches |
| Refresh-ahead | Cache miss spikes at high criticality | Medium | For frequently read, expensive values |
| Soft TTL / stale-while-revalidate | Direct backend load on cache miss | High | For systems with strict latency requirements |
In practice, production systems often combine several of these TTL strategies: TTL jitter as a baseline protection for all batch writes, complemented by refresh-ahead for especially critical, frequently read keys. The combination addresses both the problem of synchronous mass expirations and the problem of individual but expensive cache misses.
10. Summary
A well thought out Redis TTL strategy goes well beyond simply setting EXPIRE. Passive expiration guarantees correct responses on every access, active expiration cleans up unused, expired keys in the background, both mechanisms work hand in hand and only the master propagates deletions to replicas. The central risk without a deliberate TTL strategy is the thundering herd effect, where many keys expire simultaneously and flood the backend with a wave of requests.
TTL jitter solves this problem with minimal effort by spreading expiration times over a time window instead of letting all keys expire at exactly the same moment. For especially critical use cases, refresh-ahead and soft TTL complement this baseline strategy with proactive refresh and stale-while-revalidate behavior. Anyone setting TTL values in production Redis environments should always ask how many other keys will get the same or a very similar TTL, before adopting a fixed value without reflection.
Redis TTL strategies, the essentials at a glance
EXPIRE, TTL, PERSIST
EXPIRE sets an expiration time, TTL queries the remaining time, PERSIST removes the TTL entirely.
Two deletion mechanisms
Passive expiration on access guarantees correctness, active expiration cleans up periodically in the background.
Avoiding thundering herd
Identical TTLs on many keys lead to synchronous mass expiration and sudden backend load.
TTL jitter as the default solution
A random offset on the base TTL spreads expiration times and significantly lowers load spikes.