Redis expiration and eviction: did the key vanish because it expired or because memory ran out? Basic
Estimated reading time: 8 min read
The problem
Section titled “The problem”An API reads orders from PostgreSQL and keeps a copy in Redis for 10 minutes. One afternoon database load rises because of more cache misses, and the key order:42 disappears before its expiry.
The hasty conclusion “Redis’s TTL is broken” is usually wrong: a key can vanish through two independent paths.
| Expiration | Eviction | |
|---|---|---|
| Cause | The key’s expiry time has passed | Redis hit maxmemory and needs to make room |
| Can it happen before the TTL? | No | Yes |
| Metric | expired_keys |
evicted_keys |
| Fix | Tune the TTL, add jitter | Add RAM, shrink big keys, change the policy |
Raising the TTL while Redis is evicting because of memory pressure only makes things worse.
Redis’s real defaults
Section titled “Redis’s real defaults”| Setting | Default | Consequence |
|---|---|---|
maxmemory |
0 (unlimited, on 64-bit systems) |
Never evicts; Redis grows until the OS OOM killer steps in |
maxmemory-policy |
noeviction |
When full: writes are rejected, reads still work |
maxmemory-samples |
5 |
LRU/LFU is only approximate |
So a Redis used as a cache without maxmemory set never evicts, and setting maxmemory without changing the policy returns write errors when full. A pure cache needs both set.
Core concepts
Section titled “Core concepts”TTL is a key’s remaining lifetime. SET key v EX 600 writes the value with an expiry; TTL returns -1 if the key exists without an expiry and -2 if the key does not exist.
An overwriting SET clears the key’s TTL (use KEEPTTL to keep it), while INCR, LPUSH, HSET leave the TTL intact.
How Redis deletes expired keys
Section titled “How Redis deletes expired keys”- Lazy (passive): a key is deleted when a command accesses it and finds it expired.
- Active: per Redis’s latency documentation, a cycle runs 10 times per second; each cycle samples 20 keys with a TTL, deletes the expired ones, and repeats if more than 25% of the sample had expired.
Consequence: an expired key can keep occupying RAM for a while, and if a great many keys expire in the same second (25% or more of keys with a TTL), Redis can block while cleaning up. This is why TTLs need jitter.
Replicas don’t expire keys on their own
Section titled “Replicas don’t expire keys on their own”The master deletes a key and then sends a DEL to replicas; a replica does not expire keys independently (but it keeps enough expiry information to take over when promoted to master). Consequences:
- While waiting for the
DELto arrive, an expired key still occupies memory on the replica. - Per Redis’s replication documentation, replicas use a logical clock to report the key as nonexistent for reads, so clients reading from a replica usually don’t see expired values.
Conversely, per redis.conf, since Redis 5 replicas ignore their own maxmemory by default (replica-ignore-maxmemory yes); eviction happens on the master.
The policies
Section titled “The policies”| Policy | Candidates | Notes |
|---|---|---|
noeviction (default) |
None | Writes are rejected when full |
allkeys-lru |
All keys | Approximate LRU; Redis recommends it as the default choice absent other reasons |
allkeys-lfu |
All keys | Keeps frequently accessed keys; tune with lfu-log-factor (10), lfu-decay-time (1 minute) |
allkeys-random |
All keys | Fits when keys are accessed evenly |
volatile-lru/lfu/random |
Only keys with a TTL | Fits when one instance holds both cache and persistent keys |
volatile-ttl |
Keys with a TTL | Evicts keys closest to expiry first |
Every volatile-* policy behaves like noeviction when no keys with a TTL remain. maxmemory-samples (default 5) is the number of keys sampled per eviction; raising it to 10 approximates true LRU better at a small CPU cost.
Choosing a policy by workload
Section titled “Choosing a policy by workload”| Workload | Policy | Reason |
|---|---|---|
| Pure cache, a few hot keys | allkeys-lru (or allkeys-lfu) |
Exploits skewed access distribution |
| Pure cache, uniform access | allkeys-random |
No “hot” keys to favor |
| Cache mixed with sessions/persistent keys in one instance | Split instances; if unavoidable, volatile-* |
allkeys-* can evict sessions too |
| Queues, locks, data that must not be lost | noeviction |
Better a write error than silent data loss |
How it works
Section titled “How it works”SET EXThe application writes a cache key with a TTL. Redis stores the value and its expiration time.
Timeline of a key set with SET EX 2:
| Point | Observation | Note |
|---|---|---|
| T0 | Key has a value, TTL about 2 |
|
| T0 + 1 s | Still there, or already evicted under memory pressure | From the application’s view, eviction looks like expiration |
| T0 + 2.3 s | GET returns nil, TTL = -2 |
Whether the bytes are freed early or late is internal |
The TTL is an upper bound on staleness, not a guarantee of fresh data within that window. The order can change in PostgreSQL right after the cache write; see cache-aside.
Diagnosis: what to check when misses rise
Section titled “Diagnosis: what to check when misses rise”Loading diagram…
Commands to run:
redis-cli INFO stats | grep -E "expired_keys|evicted_keys|keyspace_hits|keyspace_misses"redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_not_counted_for_evict"redis-cli CONFIG GET maxmemory-policyHit rate = keyspace_hits / (keyspace_hits + keyspace_misses). Don’t add expired_keys + evicted_keys and call it the miss count: an expired key nobody reads causes no miss, while one evicted hot key causes thousands.
Real-world example
Section titled “Real-world example”Local demo
Section titled “Local demo”docker compose -f examples/docker-compose.yml up -d redisnpx --prefix examples ts-node examples/src/redis-expiration/ttl-demo.tsdocker compose -f examples/docker-compose.yml stop redisThe script does three steps:
- Writes a key that lives 2 seconds, prints the value and TTL (
before: cached-order, TTL near 2). - Waits 2.3 seconds and reads it again (
after: null TTL: -2). - Prints the container’s
maxmemory-policy, usuallynoevictionper the defaults above.
The demo only proves expiration; it does not push Redis to maxmemory. To see eviction, use a separate test Redis with a small maxmemory and read INFO stats; don’t CONFIG SET maxmemory on an instance serving real data.
Trade-offs
Section titled “Trade-offs”What TTLs and policies give you
- A TTL bounds a cache entry’s lifetime and automatically cleans up data nobody reads
- Eviction lets Redis manage memory itself when the cache grows beyond expectations
- Multiple policies let you favor hot keys or keys close to expiry
Limits to plan for
- Long TTLs raise the risk of stale data; short TTLs raise misses and DB load
- Eviction can delete keys that still have TTL left, dragging the hit rate down
- The wrong policy for a mixed workload can evict important data or make writes fail
Production checklist
Section titled “Production checklist”- Set
maxmemoryexplicitly and leave RAM for replication/AOF buffers (seemem_not_counted_for_evictinINFO memory); these buffers do not count towardmaxmemory. - Set
maxmemory-policyto match the workload; don’t leave the defaultnoevictionon a pure cache. - Don’t mix cache with data that must not be lost in one instance; split instances.
- Jittered TTLs for keys loaded in bulk.
- Alert on the growth rate of
evicted_keysand on hit rate, not only onused_memory. - Replicas: expired keys occupy memory until the master’s
DELarrives (reads on the replica already report them as nonexistent); monitor replica memory. - Redis must not be the only store of business data if eviction is enabled.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) evicted_keys rises while many keys still have TTL left: does raising the TTL help? (2) Why can volatile-ttl make writes fail even when keys without a TTL remain? (3) Why is an overwriting SET a TTL trap?