LRU vs LFU Eviction Policies in Redis

This guide decides which maxmemory-policy to run when Redis hits its memory ceiling — approximate LRU, LFU, or their volatile-* variants — and how to tune, roll out, and verify that choice under production traffic.

Eviction is not a fallback that only fires when something has gone wrong; it is a deterministic capacity-control layer that runs every time the working set exceeds maxmemory. When proactive lifecycle controls such as TTL and explicit invalidation fail to constrain growth, the eviction algorithm alone decides which keys survive — and that decision directly sets your cache hit ratio, downstream database load, and p99 latency during traffic spikes. The broader context for where eviction sits among the other caching decisions is laid out in Redis Caching Architecture & Invalidation Fundamentals; this page focuses purely on the recency-versus-frequency trade-off.

Choosing a Redis maxmemory-policy from the dominant access pattern A four-way decision tree. From the root question about the dominant access pattern, four branches lead to allkeys-lru for recency and uniform popularity, allkeys-lfu for frequency and a stable hot set, volatile-lru or volatile-lfu when only TTL-bearing keys are evictable, and noeviction with client-side TTL when data must never be dropped. Dominant access pattern? recency, fairly uniform frequency, stable hot set only TTL-bearing keys evictable must never drop data allkeys-lru evict oldest idle key API & fragment caches allkeys-lfu evict least-frequent key sessions, hot reference volatile-lru / volatile-lfu evicts TTL keys only untagged keys protected noeviction + client-side TTL writes fail at maxmemory app enforces its bounds

Architectural Trade-offs: LRU vs LFU

Both policies answer the same question — "which key do I drop to reclaim memory?" — but they read a different signal. LRU (Least Recently Used) evicts by recency of access; LFU (Least Frequently Used) evicts by frequency of access tracked through a decaying counter. The consequence is a divergence in which keys survive a memory-pressure event, and therefore which requests still hit cache afterwards.

Dimension Approximate LRU (allkeys-lru) LFU (allkeys-lfu)
Eviction signal Time since last access (idle time) Access frequency with logarithmic decay
Hit ratio, recency-skewed load High — recent keys are the hot keys Moderate — new hot keys start cold and can be evicted early
Hit ratio, frequency-skewed load Lower — a traffic burst can flush long-lived hot keys High — durable hot set is protected from transient spikes
Scan / burst resistance Weak — a full scan pollutes recency for every key Strong — one-off accesses barely move the counter
Per-key metadata 24-bit access clock in the object header 8-bit log counter in the same header (OBJECT FREQ)
CPU cost Sampling only (maxmemory-samples) Sampling plus counter increment/decay math
Primary tunable maxmemory-samples lfu-log-factor, lfu-decay-time
Best-fit workload API response caches, rendered fragments, uniform key popularity Sessions, auth tokens, reference data, Zipfian key popularity

The recurring failure this table predicts: run LRU under a frequency-skewed workload and a single batch job or crawler that touches many cold keys will make them all "recently used," evicting the durable hot set your read path depends on. LFU exists precisely to make that class of access cheap to ignore.

Approach A — Approximate LRU: Sampling Mechanics and Tuning

Redis deliberately avoids strict LRU. A true LRU cache maintains a global doubly-linked list and moves a node to the head on every access, which costs memory per key and serializes the hot path. Instead Redis stores a small access clock in each object header and, at eviction time, samples a configurable number of keys (maxmemory-samples, default 5), then evicts the one with the oldest idle time among that sample. It is an approximation of LRU whose accuracy rises with the sample size.

The default sample of 5 is adequate when key popularity is fairly uniform, but it degrades under skew: with too few samples the chance of catching the genuinely-coldest key in any given eviction drops, so warm keys get evicted alongside cold ones and the hit ratio wobbles. Raising maxmemory-samples to 10–15 tightens the approximation toward true LRU at a small, measurable CPU cost. That precise tuning for write-heavy, high-QPS endpoints is worked through in Configuring LRU Eviction for High-Throughput APIs.

Approximate LRU: evict the oldest-idle key within a random sample Twelve keys with idle times are shown. Five are randomly sampled (outlined). The oldest-idle key in the sample, k06 at 190 seconds, is evicted. An unsampled key, k02 at 240 seconds, is actually colder but survives this round. A larger maxmemory-samples value makes such misses rarer. Sample N keys, evict the oldest idle time in that sample idle time = seconds since last access · higher = colder k01 12s k02 240s k03 45s k04 88s k05 8s k06 190s k07 30s k08 62s k09 15s k10 5s k11 110s k12 26s EVICT — oldest sampled colder, but not sampled sampled (N = maxmemory-samples) evicted (oldest in sample) colder key the sample missed Raising maxmemory-samples widens the sample toward the whole keyspace, so eviction converges on true LRU and misses fewer cold keys like k02.
# Runtime adjustment (non-persistent; also edit redis.conf for durability)
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG SET maxmemory-samples 10

# Persistent configuration (redis.conf)
#   maxmemory-policy  allkeys-lru
#   maxmemory-samples 10

Because eviction tuning is an administrative operation rather than a hot-path call, a synchronous client is appropriate here. The routine below is idempotent — it only writes when the running value differs — so it is safe to call from a config-reconciliation loop.

import redis
from redis.exceptions import ResponseError

# redis-py 5.x, synchronous admin client for config reconciliation
client = redis.Redis(host="cache-primary.internal", port=6379, decode_responses=True)


def tune_lru_sampling(target_samples: int = 12) -> None:
    """Align maxmemory-samples with the running config, writing only on drift."""
    try:
        current = client.config_get("maxmemory-samples")
        if int(current["maxmemory-samples"]) != target_samples:
            client.config_set("maxmemory-samples", target_samples)
            print(f"maxmemory-samples set to {target_samples}")
    except ResponseError as exc:
        # CONFIG SET is blocked on managed tiers (e.g. some hosted Redis)
        print(f"config locked or unsupported: {exc}")


def eviction_velocity() -> int:
    """Cumulative evicted_keys — sample twice and diff to get a rate."""
    return client.info("stats").get("evicted_keys", 0)

Approach B — LFU: Logarithmic Counters and Decay

LFU replaces the recency signal with an estimate of access frequency. Every key carries an 8-bit logarithmic counter stored in the same object header field the LRU clock would use; OBJECT FREQ <key> returns its current value (0–255). The counter is not a raw hit count — that would saturate almost immediately and could never distinguish a key accessed a thousand times from one accessed a billion. Instead, on each access the counter increments probabilistically, and the probability of incrementing shrinks as the counter grows, governed by lfu-log-factor. Separately, lfu-decay-time (default: one minute per halving-step) ages the counter downward so a key that was hot last year but idle today loses its protection.

This decay-and-log design is what makes LFU resistant to bursts: a scan or a one-off crawl nudges each touched key's counter by a tiny probabilistic amount, nowhere near enough to displace a genuinely durable hot key. LFU therefore shines in stateful workloads — sessions, auth tokens, shopping carts, hot reference data — where a temporal spike must not flush long-lived, high-value objects. A lower lfu-log-factor (1–5) lets counters climb quickly so hot keys separate from cold ones fast; a higher factor (50–100) delays saturation and preserves finer gradations across a tiered access distribution.

The LFU logarithmic counter versus a raw saturating hit count Two curves against a counter axis from 0 to 255. The raw hit count spikes to the 255 ceiling and stays pinned, losing all resolution. The LFU log counter rises with a diminishing slope set by lfu-log-factor, dips during an idle period as lfu-decay-time ages it, then climbs again, keeping durable hot keys separable from transient ones. 255 128 0 8-bit ceiling (255) pins to 255 — no resolution diminishing increment prob. (lfu-log-factor) decays while idle (lfu-decay-time) accesses over time → LFU log counter raw hit count
# Enable LFU with a fast-climbing counter and default decay
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli CONFIG SET lfu-log-factor 5
redis-cli CONFIG SET lfu-decay-time 1

# Inspect a live key's frequency counter (0-255)
redis-cli OBJECT FREQ session:user:8a3f9c

To validate that LFU is doing its job, sample the counter distribution across your hot prefix. A healthy hot set shows a clear separation between high-counter durable keys and low-counter transient ones; a flat distribution means lfu-log-factor is too high for your access volume and the policy cannot tell hot from cold.

import asyncio
import redis.asyncio as redis

# redis-py 5.x asyncio client — safe to run against a live node under load
async def sample_freq_distribution(prefix: str = "session:", limit: int = 500) -> dict[int, int]:
    """Bucket OBJECT FREQ values across a key prefix to inspect hot/cold separation."""
    client = redis.Redis(host="cache-primary.internal", port=6379, decode_responses=True)
    buckets: dict[int, int] = {}
    scanned = 0
    async for key in client.scan_iter(match=f"{prefix}*", count=100):
        freq = await client.object("freq", key)  # requires maxmemory-policy = *-lfu
        bucket = int(freq) // 16  # 16 coarse buckets across the 0-255 range
        buckets[bucket] = buckets.get(bucket, 0) + 1
        scanned += 1
        if scanned >= limit:
            break
    await client.aclose()
    return dict(sorted(buckets.items()))

When to Choose Which

Tie the decision to concrete production signals, not intuition:

  1. Reach for allkeys-lru when key popularity is roughly uniform or genuinely recency-driven — rendered page fragments, GraphQL/REST response caches, short-lived computed payloads — and when keyspace_misses stays low with the default sampling. This is the safe default for a cache whose value is its recency.
  2. Reach for allkeys-lfu when a stable hot set must survive traffic that periodically touches a large cold surface — user sessions, feature flags, product catalog rows read on every request. The tell is a hit ratio that collapses during batch windows or crawler traffic under LRU: that is the durable hot set being flushed, and LFU stops it.
  3. Reach for volatile-lru / volatile-lfu when a single instance mixes evictable cache entries with keys that must never be dropped (durable state, locks, queues). These variants only ever evict keys that carry an explicit TTL, so untagged keys are protected — at the risk of an out-of-memory error if too few keys are evictable.
  4. Reach for noeviction only when dropping any key silently is unacceptable and the application enforces its own bounds. Writes then fail with an OOM error once maxmemory is reached, which must be handled explicitly by the client rather than discovered in production.

If you cannot yet characterise the workload, start on allkeys-lru with maxmemory-samples 10, measure for a week, and switch to LFU only if the hit ratio proves burst-sensitive.

Topology-aware consistency

Eviction is enforced per node. In a clustered deployment every primary applies its own maxmemory and evicts against only its local keyspace, so a policy set on one shard does not propagate. Misaligned policies across nodes produce uneven memory pressure and an unpredictable, shard-dependent hit ratio. The mapping of keys to nodes is governed by the hash slot assignment, and a live slot migration can move a hot key onto a shard running a different policy — a subtle source of post-migration miss spikes. Apply policy uniformly across every node, and understand how it fits the wider topology in Understanding Redis Cache Topology.

# Apply one eviction policy uniformly across every primary in the cluster
redis-cli --cluster call <cluster-node-ip>:6379 CONFIG SET maxmemory-policy allkeys-lfu
redis-cli --cluster call <cluster-node-ip>:6379 CONFIG SET lfu-log-factor 5

Failure Modes and Diagnostics

Three eviction-specific failures account for most production incidents in this area.

1. Eviction churn (thrash). maxmemory sits just below the true working-set size, so Redis evicts a key and then immediately re-populates it on the next miss, burning CPU and hammering the origin. The signature is a high evicted_keys rate combined with a high keyspace_misses rate — steady-state eviction of keys that are still wanted.

# High evicted_keys AND high misses = thrash, not healthy capacity control
redis-cli INFO stats | grep -E "evicted_keys|keyspace_hits|keyspace_misses"
redis-cli INFO memory | grep -E "used_memory:|maxmemory:|mem_fragmentation_ratio"

The fix is capacity, not policy: raise maxmemory, shard the keyspace, or shorten TTLs so the working set genuinely fits.

2. Hot-set flush under LRU. A batch job, analytics scan, or crawler reads a large cold surface, marking every touched key "recently used." Under allkeys-lru the durable hot set is now the least recently used and gets evicted, and the hit ratio collapses precisely when the batch finishes. Diagnose by correlating a miss-ratio spike with a known batch window; the fix is allkeys-lfu, whose decaying counter ignores one-off scans.

3. Stale retention under LFU. With lfu-decay-time set too high, keys that were once hot keep an inflated counter long after they go cold, occupying memory that never serves traffic while newer hot keys are evicted for lack of space. Diagnose by sampling OBJECT FREQ on suspected-stale keys and confirming high counters with old idle times; lower lfu-decay-time so counters age faster.

Hot-set flush: a cold scan makes the durable hot set the LRU eviction victim A batch scan of cold keys refreshes their LRU recency, so the durable hot set becomes least-recently-used and is evicted next. The following user request for hot:session misses in Redis and falls through to the origin database, spiking origin load. Batch job (scan) Redis allkeys-lru User request Origin database GET cold:1 … cold:N cold keys → most-recently-used hot set → least-recently-used evict hot:session — now the LRU victim GET hot:session nil — cache MISS SELECT … (fallthrough) row — origin load ↑ One cold scan flips the durable hot set to least-recent under LRU; the next eviction drops it, so real user traffic falls through to the origin database.

The link between eviction frequency and origin load is non-linear: aggressive eviction on read-heavy endpoints can trigger thundering-herd re-population, while poorly calibrated LFU decay quietly wastes memory. Treat both evicted_keys and the hit ratio as coupled signals, never in isolation.

Verification

Confirm the policy is actually live and behaving on the node — not just present in a config file.

# 1. Confirm the running policy and tunables (not just redis.conf)
redis-cli CONFIG GET maxmemory-policy
redis-cli CONFIG GET maxmemory-samples
redis-cli CONFIG GET 'lfu-*'

# 2. Confirm eviction is happening for the right reason
redis-cli INFO stats | grep evicted_keys

# 3. Compute the live hit ratio (want > 0.90 for most caches)
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"

For continuous validation, deploy the Redis Exporter and alert on eviction rate, memory headroom, and hit-ratio degradation:

# Eviction is occurring (correlate with misses before paging)
rate(redis_evicted_keys_total[5m]) > 0

# Memory pressure approaching the ceiling
redis_memory_used_bytes / redis_memory_max_bytes > 0.85

# Hit ratio has dropped below SLO
rate(redis_keyspace_hits_total[5m])
  / (rate(redis_keyspace_hits_total[5m]) + rate(redis_keyspace_misses_total[5m]))
  < 0.90

A tested rollout follows the same shape every time: record a 7-day baseline of evicted_keys, keyspace_misses, and used_memory under the current policy; apply the candidate policy to a shadow node replaying production traffic; promote via CONFIG SET on a single replica and watch the hit ratio hold; then roll to primaries, keeping the previous policy in version control so CONFIG SET can revert within the SLO window if p99 regresses.

Parent topic

Up one level: Redis Caching Architecture & Invalidation Fundamentals