Redis Expire and TTL Strategies Correctly Applied
AI generated
SET
TTL
Redis · TTL · EXPIRE · Cache Strategies
Redis expire and TTL strategies, correctly applied
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.

13 min read EXPIRE · TTL · PERSIST · TTL jitter Redis 6.x · 7.x · redis-cli

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.

11. FAQ: Redis Expire and TTL Strategies Correctly Applied

1EXPIRE vs. PEXPIRE?
EXPIRE in seconds, PEXPIRE in milliseconds for more precision.
2TTL -1 and -2?
-1: exists without TTL, -2: key does not exist.
3Active vs. passive?
Passive checks on access, active cleans up periodically in background.
4How often does the cycle run?
10x per second by default, adaptive at high expiry rates.
5What is thundering herd?
Many keys expire at once, backend gets flooded with requests.
6How does TTL jitter work?
Random offset per key on the base TTL, spreads expiration times.
7How large should jitter be?
5 to 10 percent of the base TTL as a common starting point.
8What does PERSIST do?
Removes TTL entirely, key becomes permanent without expiry.
9Do replicas delete on their own?
No, only the master, replicas wait for explicit DEL.
10Jitter vs. refresh-ahead?
Jitter spreads expirations, refresh-ahead proactively renews before expiry.