Ch. 27 · Redis

Redis Eviction Policies and Key Expiry: LRU, LFU and TTL

How Redis expires keys lazily and actively, what each maxmemory-policy evicts, and how approximated LRU and LFU choose a victim.

~8 min readintermediateupdated Oct 6, 2026

Interviewers rarely ask “what is a TTL?” on its own. They ask “what happens when Redis runs out of memory?”, “what is the difference between allkeys-lru and volatile-lru?”, or “why did our cache start rejecting writes?” These questions test whether you understand two separate mechanisms that people often blur together: expiry, which removes keys whose time is up, and eviction, which removes keys to stay under a memory limit.

This guide covers both for Redis 7.4 and 8.x, including how Redis approximates LRU and LFU, and the configuration mistakes that turn a cache into an outage. Valkey uses the same policies.

Before you start

You should be comfortable with basic SET/GET, and know what a cache hit ratio is: the share of reads served from the cache. LRU (least recently used) and LFU (least frequently used) are general cache replacement ideas; you do not need to know their textbook implementations, because Redis does not use them. The examples use redis-cli and Python with redis-py.

The short answer

A TTL marks a key for deletion at a point in time. Redis removes expired keys lazily, when a client touches them, and actively, with a background cycle that samples keys with a TTL. Eviction is separate: when memory would exceed maxmemory, Redis applies maxmemory-policy. The default, noeviction, rejects writes with an OOM error; allkeys-lru or allkeys-lfu evict from all keys and suit a pure cache; volatile-* policies only evict keys that have a TTL. LRU and LFU are approximations based on sampling a few keys, not exact orderings.

How it works

Expiry

127.0.0.1:6379> SET session:42 alice EX 600
OK
127.0.0.1:6379> TTL session:42
(integer) 600
127.0.0.1:6379> TTL missing
(integer) -2
127.0.0.1:6379> PERSIST session:42
(integer) 1
127.0.0.1:6379> TTL session:42
(integer) -1
Terminal

TTL returns -1 for a key without an expiry and -2 for a key that does not exist. Internally Redis keeps a second dictionary from keys to absolute expiry times in milliseconds.

Lazy expiry: before any command reads or writes a key, Redis checks the deadline and deletes the key if it has passed. A client never sees an expired value. Active expiry: serverCron runs hz times a second (10 by default) and samples keys from the expires dictionary, deleting the expired ones and continuing while a large fraction of the sample turns out to be expired, within a CPU budget. active-expire-effort (1 to 10, default 1) trades CPU for faster reclamation.

Two consequences: memory used by expired keys that nobody reads is reclaimed gradually, not instantly; and replicas do not expire keys themselves. The primary sends a DEL when it expires a key, and since Redis 7.0 relative expiries are propagated as absolute timestamps (PEXPIREAT), so replicas and the AOF agree on the deadline.

Eviction

Before executing a command that may grow memory, Redis compares used memory (minus replication and AOF buffers) with maxmemory. If it is over, it evicts keys according to the policy until it is back under, or returns an error.

Policy Candidates Picks Typical use
noeviction (default) none writes fail with OOM primary data store
allkeys-lru all keys least recently used general cache
allkeys-lfu all keys least frequently used cache with stable hot set
allkeys-random all keys random uniform access
volatile-lru keys with TTL least recently used cache and durable data mixed
volatile-lfu keys with TTL least frequently used same, frequency-based
volatile-random keys with TTL random rarely the best choice
volatile-ttl keys with TTL shortest remaining TTL TTL encodes priority

Exact LRU would need a linked list through every key, updated on every access. Instead, each object header stores a 24-bit field. Under LRU it is a coarse last-access clock; to evict, Redis samples maxmemory-samples keys (5 by default), computes idle times and keeps the best candidates in a 16-entry eviction pool that persists between rounds. With 10 samples the result is very close to true LRU.

Under LFU the same 24 bits hold an 8-bit logarithmic counter and a 16-bit minute timestamp. Each access increments the counter with a probability that falls as it grows (lfu-log-factor, default 10, lets 255 represent about a million accesses), and the counter is decremented as minutes pass without access (lfu-decay-time, default 1). Frequency, not recency, decides the victim.

Step-by-step walkthrough

Step 1: Set TTLs deliberately

Every cache key should have a TTL that bounds staleness. Watch for commands that silently drop it:

SET price:42 39 EX 300
SET price:42 41              # TTL is gone: plain SET clears it
SET price:42 41 KEEPTTL      # keeps the existing TTL (Redis 6+)
EXPIRE price:42 600 GT       # only extend, never shorten (Redis 7+)
EXPIRE price:42 60 NX        # only if the key has no TTL yet
Terminal

INCR, HSET, LPUSH, ZADD and RENAME keep the TTL. GETEX key EX 300 reads and refreshes in one command, which is handy for sliding session expiry.

Step 2: Configure the memory limit and policy

# redis.conf for a dedicated cache on a 16 GB machine
maxmemory 10gb
maxmemory-policy allkeys-lfu
maxmemory-samples 10
Terminal

Leave headroom below physical RAM. Replica output buffers, client buffers, the AOF rewrite buffer and copy-on-write pages during a fork all live outside maxmemory, so a process limited to 10 GB can briefly need several GB more.

Step 3: Choose LRU or LFU from the access pattern

LRU works when recency predicts reuse, such as sessions or a news feed. LFU is better when popularity is skewed and stable, and when occasional scans touch many cold keys once: a nightly export that reads a million rarely used products would make them all “recently used” and push hot keys out under LRU, but it barely raises their LFU counters.

redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli OBJECT FREQ product:42      # logarithmic counter, only under an LFU policy
redis-cli --hotkeys                   # scans with SCAN and reports the highest counters
Terminal

Step 4: Add jitter so keys do not expire together

If a deploy warms 50,000 keys with the same 300-second TTL, they all expire in the same second and the database takes the full reload at once. Randomize:

import random

def cache_set(r, key, value, base_ttl=300):
    r.set(key, value, ex=base_ttl + random.randint(0, base_ttl // 5))
python

A 20% spread turns one spike into a gentle five-minute slope.

Step 5: Monitor expiry and eviction separately

redis-cli INFO stats | grep -E 'expired_keys|evicted_keys|keyspace_hits|keyspace_misses'
redis-cli INFO keyspace
# db0:keys=1203344,expires=1203001,avg_ttl=181234,subexpiry=0   (subexpiry: hashes with field TTLs, 7.4+)
Terminal

Rising evicted_keys with a falling hit ratio means the working set no longer fits: add memory, shard, or cache less. expires lower than keys on a cache instance means some writers forget TTLs.

Worked scenario

A team ran sessions, carts and a product cache on one Redis primary with maxmemory 4gb and maxmemory-policy volatile-lru, reasoning that only cache keys have TTLs, so sessions and carts are safe. A new caching library was introduced with a default TTL of 0, meaning “no expiry”.

Within a day, memory hit 4 GB. Cache keys had no TTL, so volatile-lru found almost nothing to evict, and Redis started rejecting writes:

127.0.0.1:6379> HSET cart:9001 sku:111 1
(error) OOM command not allowed when used memory > 'maxmemory'.
Terminal

Carts could not be updated, checkout failed, and reads kept working, which made the incident confusing at first. INFO keyspace showed expires at a tiny fraction of keys.

The fix had three parts:

# 1. Every cache write sets a TTL
cache = RedisCache(client, default_ttl=300)        # never 0 for cache data
python
# 2. Split roles: durable data on an instance that never evicts...
maxmemory-policy noeviction
# ...and the cache on its own instance that evicts anything
maxmemory-policy allkeys-lfu
Terminal
  1. An alert on evicted_keys rate and on the expires/keys ratio of the cache instance. Mixing durable data and cache on one instance with a volatile-* policy works only while every writer is disciplined about TTLs; separate instances make the guarantee structural.

Common mistake

  • “Expired keys are deleted exactly at their TTL.” They become invisible immediately but are reclaimed lazily or by the sampling cycle, so memory lags behind.
  • “Redis evicts expired keys first.” Eviction and expiry are separate. Only volatile-ttl looks at remaining TTL, and expired keys are handled by the expiry mechanisms.
  • “The default policy is LRU.” It is noeviction; an unconfigured cache that fills up rejects writes.
  • “volatile-lru will evict something.” Only keys with a TTL; with none, writes fail.
  • Forgetting that SET clears the TTL. A cache refresh with plain SET creates an immortal key.
  • Sizing maxmemory to physical RAM. Buffers and fork copy-on-write push the process past it, and the OOM killer ends it.

Verify the behavior

Reproduce eviction on a throwaway instance with a tiny limit:

redis-cli CONFIG SET maxmemory 5mb
redis-cli CONFIG SET maxmemory-policy noeviction
python3 -c "
import redis; r = redis.Redis(port=6379)
try:
    for i in range(100000): r.set(f'k:{i}', 'x' * 100)
except redis.exceptions.ResponseError as e: print(i, e)
"
# prints the index of the first rejected write and the OOM message

redis-cli CONFIG SET maxmemory-policy allkeys-lru
python3 -c "
import redis; r = redis.Redis(port=6379)
for i in range(100000, 200000): r.set(f'k:{i}', 'x' * 100)
"
redis-cli INFO stats | grep evicted_keys      # now non-zero
redis-cli DBSIZE                              # far fewer than 200000 keys
Terminal

To see lazy expiry, set a key with PX 100, wait, and check INFO stats expired_keys before and after a GET of that key; with many such keys and no reads, the active cycle increases the count over a few seconds.

Follow-up questions

What does maxmemory-samples trade off? More samples give a closer approximation of true LRU or LFU at a higher CPU cost per eviction. 5 is the default; 10 is common for caches.

How does eviction interact with replicas? Only the primary evicts; it sends DEL to replicas. Replicas ignore their own maxmemory for evictions by default (replica-ignore-maxmemory yes).

Can you expire a single field of a hash? Yes, since Redis 7.4 with HEXPIRE, HPEXPIRE and HTTL.

Why might a cache with allkeys-lru still return OOM errors? If a single write needs more memory than eviction can free, or if eviction cannot keep up with the write rate, Redis can still reject the command.

Interview exercise

A product catalogue cache has a 20 GB maxmemory with allkeys-lru. Every night at 02:00 a reporting job reads every product once through the same cache client. Each morning the hit ratio drops from 97% to 70% and recovers by noon. Explain why and propose a fix.

Answer and reasoning

The reporting job touches every product, so under LRU millions of cold products become the most recently used keys and the genuinely hot ones are evicted to make room; the morning traffic then misses and reloads them. Fixes, in order of preference: make the job bypass the cache (read from a replica database or call the loader directly without populating Redis), because a one-off scan should not be cached at all. Then switch the cache to allkeys-lfu: a single access barely raises a cold key’s logarithmic counter, while hot keys keep high counts, so the scan no longer flushes them. Finally, alert on evicted_keys and hit ratio so a regression is caught the same night.

Continue learning

Practise with the Redis interview questions and the Redis MCQs. Related notes: Redis data structures and when to use each one, Spring Boot caching abstraction for TTLs configured from application code, and Networking: DNS caching and TTL for the same staleness trade-off elsewhere. Primary sources: Key eviction, the EXPIRE command reference and the Redis configuration page with the self-documented redis.conf.

More in Redis

esc