What happens when Redis hits maxmemory
The maxmemory directive caps how much memory Redis may use for data. With no limit set, which is the default on 64-bit builds, Redis keeps allocating until the operating system pushes back. That usually means swapping, which destroys latency, or the kernel's OOM killer terminating the process. Setting maxmemory moves that decision into Redis. The eviction policy (maxmemory-policy) tells Redis what to do when a command would push usage past the limit.
The default policy is noeviction. Under it, Redis removes nothing. Any command that needs more memory, such as a SET of a new key or an append to a list, is rejected with an OOM error. Reads keep working, and so do deletes, because they don't grow the dataset. The instance stays up but becomes read-only for new data until something frees memory.
For a primary data store, that is often what you want. Silently dropping a session record or a queue entry is worse than a loud write failure your application can catch.
A cache has different priorities. Every entry can be rebuilt from the source of truth, so losing a cold key costs one extra backend lookup. Refusing writes is worse: fresh values can't be stored, and the miss rate climbs exactly when the cache is full and under load. The other eviction policies exist for this case. Before each memory-growing write, Redis removes keys chosen by the policy's criteria so the write can succeed. The policy you pick determines which keys are removed, and that choice is what the rest of this article covers.
How Redis eviction policies work: families and selection criteria
Policies come in two families. allkeys-* policies treat every key as an eviction candidate. volatile-* policies only consider keys that have an expiry set, so keys without a TTL are never evicted. The suffix sets how a victim is chosen among the candidates:
- lru: the least recently accessed key.
- lfu: the least frequently accessed key. Redis keeps a small access counter that decays over time.
- random: any candidate.
- ttl (volatile only): the key with the shortest remaining time to live.
| Policy | Candidate set | Selection criterion | No candidates available |
|---|---|---|---|
| allkeys-lru | All keys | Oldest access (approximate) | Only if the dataset is empty; write fails with OOM |
| allkeys-lfu | All keys | Lowest access frequency (approximate) | Same as above |
| allkeys-random | All keys | Random | Same as above |
| volatile-lru | Keys with a TTL | Oldest access (approximate) | Write fails with OOM |
| volatile-lfu | Keys with a TTL | Lowest access frequency (approximate) | Write fails with OOM |
| volatile-random | Keys with a TTL | Random | Write fails with OOM |
| volatile-ttl | Keys with a TTL | Shortest remaining TTL (approximate) | Write fails with OOM |
Redis does not keep a global ordering of all keys, because maintaining one would cost memory and CPU on every access. It samples a few candidates, compares them against a small pool of earlier good candidates, and evicts the best one. maxmemory-samples controls the sample size. Larger values get closer to true LRU, LFU or TTL ordering but use more CPU on each eviction. Do not expect strict ordering. A recently used key can occasionally be evicted before an older one.
write arrives ──► used memory > maxmemory?
│ no ──────────────► accept write
│ yes
▼
sample candidates ──► none? ──► reject: OOM error
│
▼
pick victim by policy
│
▼
evict key, re-check memory
│
▼
under limit ──► accept write
┌─────────────────┐
│ write arrives │
└────────┬────────┘
▼
┌─────────────────┐ no ┌──────────────┐
┌────►│ used memory > ├──────►│ accept write │
│ │ maxmemory? │ └──────────────┘
│ └────────┬────────┘
│ │ yes
│ ▼
│ ┌─────────────────┐ no ┌──────────────┐
│ │ any candidates? ├──────►│ fail: OOM │
│ │ allkeys: any key│ │ error to the │
│ │ volatile: w/ TTL│ │ client │
│ └────────┬────────┘ └──────────────┘
│ │ yes
│ ▼
│ ┌───────────────────────────────┐
│ │ sample maxmemory-samples keys │
│ │ merge into eviction pool │
│ │ pick victim from pool: │
│ │ lru/lfu/ttl best score, or │
│ │ any sampled key for random │
│ └───────────────┬───────────────┘
│ ▼
│ ┌───────────────────────────────┐
│ │ evict victim, free memory │
│ └───────────────┬───────────────┘
└─────────────────────┘ re-check memory
Write path at maxmemory: Redis samples candidates, evicts the best approximate victim, and repeats until memory is under the limit, or rejects the write with an OOM error when the policy has no candidates.
allkeys-lru vs volatile-ttl: behavior side by side
The two policies answer "pick victim by policy" with different questions. allkeys-lru asks which key has gone longest without being touched, using approximate LRU computed from a small sample. volatile-ttl asks which key with an expiration has the least time left. That difference determines which failure mode you get.
| Aspect | allkeys-lru |
volatile-ttl |
|---|---|---|
| Candidate keys | Every key | Only keys with a TTL |
| Victim choice | Least recently accessed (sampled) | Shortest remaining TTL (sampled) |
| Hot keys | Retained | Can be evicted if their TTL is short |
Missing EXPIRE in code |
Still evicts correctly | Key can never be evicted |
| Typical failure | Assumed-permanent keys silently disappear | OOM errors on writes |
| Weak spot | Scans and one-off bursts flush the hot set | Ignores access patterns entirely |
The main risk with allkeys-lru is that nothing tells you when it evicts the wrong key. A distributed lock, a rate-limit counter or a session stored without a TTL can be dropped once it goes idle. The application then sees a cache miss and carries on, so you get a duplicate job or a bypassed limit instead of an error. A full keyspace scan or a batch import also makes cold keys look recent, which pushes out your working set. LFU resists that kind of burst better, but it adapts more slowly when the hot set genuinely changes.
volatile-ttl treats TTL as a priority signal, so a hot key with a 30-second TTL is evicted before a cold key with a 24-hour TTL. If most of your memory is held by keys without a TTL, Redis finds no eviction candidates and rejects writes as if the policy were noeviction. This usually shows up only at peak load.
Who should use which policy, and when to avoid each
For a pure cache, start with allkeys-lru. If every key can be rebuilt from the source of truth, Redis should be free to evict any of them, and recency is a reasonable proxy for how likely a key is to be read again. You don't need to set TTLs for eviction to work, so a missed expiry in application code can't fill the instance.
volatile-ttl is the right choice only when two things are true. First, TTLs deliberately encode expendability, so a shorter TTL means the key is cheaper to lose. Second, the instance also holds keys without TTLs that must never be evicted, such as sessions, distributed locks, queue state or feature config. In that setup, the TTL acts as a "may evict" flag. A key without one is protected. The risk is that protected keys can grow until no eviction candidates remain and writes start failing.
Running cache and state on separate instances is still the better design where you can. Each instance can then use its own policy, maxmemory and persistence settings, and a cache stampede can't push out your locks.
| Workload | Policy | Why |
|---|---|---|
| Pure cache, roughly uniform value | allkeys-lru |
Recency predicts reuse, and no TTL discipline is required |
| Stable hot set with strong frequency skew | Consider allkeys-lfu |
Access counts protect popular keys from one-off scans |
| Mixed data, recent use signals value | volatile-lru |
Only TTL'd keys are evicted, ranked by recency |
| Mixed data, TTL signals value | volatile-ttl |
The shortest remaining TTL is evicted first |
| Data that must never be lost | No eviction reliance | Use noeviction, persistence and capacity alerts |
How to set, persist and verify the eviction policy
Set both directives in redis.conf as maxmemory 2gb and maxmemory-policy allkeys-lru. Without a maxmemory limit, Redis has no threshold to evict against, and the policy does nothing. On a running instance, CONFIG SET maxmemory-policy volatile-ttl takes effect immediately. Confirm it with CONFIG GET maxmemory-policy and CONFIG GET maxmemory.
CONFIG SET only changes the running process. If nothing writes the change back to the config file, the next restart loads the old values without any warning. A failover or node replacement does the same thing. Run CONFIG REWRITE after the change, or update the file through your configuration management. CONFIG REWRITE only works when the server was started with a config file. Some managed deployments also restrict CONFIG commands entirely, so check where your settings actually live.
To verify behavior, check two INFO sections:
INFO memoryreportsused_memory,maxmemoryandmaxmemory_policy. Ifused_memorysits near the limit, eviction should be active.INFO statsreportsevicted_keys,keyspace_hitsandkeyspace_misses. A risingevicted_keyscount confirms Redis is evicting. Compare it withexpired_keysto separate TTL expiry from memory pressure.
Hit rate is keyspace_hits / (keyspace_hits + keyspace_misses). These counters are cumulative since startup or the last CONFIG RESETSTAT. Sample them at intervals and compute the rate from the deltas. A lifetime average hides a recent drop.
Evictions on a primary reach replicas as ordinary deletions. Replicas therefore stay consistent with the primary's eviction decisions rather than evicting on their own. Set memory limits and alerts on the primary, and make sure replicas have enough memory to hold the primary's full dataset.
#!/usr/bin/env bash
# Assumes redis.conf already contains:
# maxmemory 2gb
# maxmemory-policy allkeys-lru
set -euo pipefail
CLI="redis-cli -h 127.0.0.1 -p 6379"
# Change the policy on the running process only.
$CLI CONFIG SET maxmemory-policy volatile-ttl
$CLI CONFIG GET maxmemory-policy
$CLI CONFIG GET maxmemory
# Persist it, or the next restart silently reverts it.
# Fails if the server was not started with a config file.
$CLI CONFIG REWRITE
# Is memory near the limit, and which policy is active?
$CLI INFO memory | grep -E '^(used_memory|maxmemory|maxmemory_policy):'
# Read one counter from INFO stats (strip the trailing \r).
stat() { $CLI INFO stats | tr -d '\r' | awk -F: -v k="$1" '$1==k {print $2}'; }
# Counters are cumulative, so sample twice and use the deltas.
h1=$(stat keyspace_hits); m1=$(stat keyspace_misses)
e1=$(stat evicted_keys); x1=$(stat expired_keys)
sleep 60
h2=$(stat keyspace_hits); m2=$(stat keyspace_misses)
e2=$(stat evicted_keys); x2=$(stat expired_keys)
dh=$((h2 - h1)); dm=$((m2 - m1))
echo "evicted (memory pressure): $((e2 - e1))"
echo "expired (TTL): $((x2 - x1))"
if [ $((dh + dm)) -gt 0 ]; then
awk -v h="$dh" -v m="$dm" 'BEGIN { printf "hit rate: %.2f%%\n", 100*h/(h+m) }'
else
echo "hit rate: no reads in interval"
fi
Changes the eviction policy at runtime, persists it with CONFIG REWRITE, then samples INFO stats twice to compare evictions with TTL expirations and compute the hit rate over the interval.
Sizing maxmemory with headroom
maxmemory caps only what Redis counts as used_memory. The process footprint (RSS) the kernel and container runtime enforce is larger:
- Fragmentation. The allocator cannot always return freed memory to the OS, so RSS stays high after churn.
mem_fragmentation_ratioinINFO memoryshows the gap. - Client output buffers. Slow consumers, large
KEYS/SCANreplies, and pub/sub subscribers accumulate buffered replies. - Replication buffers. Redis excludes replica output buffers and the backlog from the eviction calculation, so they grow beyond
maxmemorywithout triggering eviction. - Fork copy-on-write. RDB snapshots and AOF rewrites fork a child. Every page the parent modifies during the dump is copied. Under heavy writes this can approach the full dataset size, and none of it appears in
used_memory.
If maxmemory equals the container limit, any of these pushes RSS over the cgroup ceiling. The kernel then kills Redis outright, often mid-snapshot. Replicas fork for their own persistence and buffer data during full syncs, so size them with the same headroom.
Worked example (illustrative estimate)
Assumed inputs:
- Container limit: 8 GiB.
- Fragmentation ratio: 1.2.
- Copy-on-write during a rewrite: 25% of the dataset.
- Buffers: 0.5 GiB.
Solve for maxmemory (M) so that the total fits within the limit:
- 1.2M + 0.25M + 0.5 ≤ 8, which gives M ≤ 5.17 GiB.
- Round down to 5 GiB.
- Check: 6 + 1.25 + 0.5 = 7.75 GiB, leaving a small margin.
Replace the assumed numbers with fragmentation and fork behavior measured on your own workload.
Alternatives and impact on cost and engineering time
Eviction is a safety valve. When it runs constantly, every evicted key that is read again becomes a miss, and each miss is a round trip to the backing database plus a write back into Redis. Latency rises, and database load scales with the miss rate. That load is what you are actually paying for.
Illustrative estimate (assumed numbers, not a benchmark). Database reads per second = cache reads per second × miss ratio. Assume 20,000 cache reads per second. At a 2% miss ratio the database sees 400 reads per second. If eviction pushes the miss ratio to 10%, it sees 2,000. No policy choice offsets that fivefold increase. Adding memory or splitting workloads does.
| Alternative | When it fits | Engineering cost |
|---|---|---|
allkeys-lfu |
Stable hot set, with scans or one-off reads that would push hot keys out under LRU | Same as allkeys-lru: a configuration change only |
noeviction + explicit TTLs |
Data must not disappear silently | Writes fail at maxmemory, so the application must handle those errors |
| Separate instances per workload | Cache and durable-ish data share one server | More instances to run, but each gets the right policy |
| More memory | The working set exceeds maxmemory |
Direct cost, with predictable results |
| Request coalescing | Hot keys expire or get evicted under load | Single-flight logic in the client |
allkeys-lru on a dedicated cache tolerates keys written without a TTL, so the application needs less expiry logic. volatile-ttl only evicts keys that have a TTL, so every write path must set one, and every new feature must remember to set it. Separating workloads removes the most expensive failure: debugging a correctness bug caused by evicting a key the code assumed was permanent.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to redis eviction policy, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.