skip to content

Lazy vs Active Expiration

You will learn that expired keys are not deleted at their deadline: they die lazily on access or when the active sampling cycle finds them, and replicas wait for the master's DEL. Interviewers ask because this explains both ghost memory usage and late expired-key notifications.

part ofRedisoverview, primer and where to startread it →
on this pageshow

questions

3

A Redis key was written with a 60-second time-to-live and 60 seconds have passed. Is the key removed from memory at that exact moment? What can a client still observe about it afterwards?

level: juniorimportance: must knowfreq 58%

answer

  1. Deadline exact, reclaim lazy
  2. No timers — delete on access
  3. expires table = only keyed TTLs
  4. Sampler mops up cold keys
  5. INFO stats: expired_keys

basics

~20 s

No. Redis frees an expired key only when a command touches it (lazy delete) or when a background sampling cycle picks it. Reads never return an expired value, but the memory can stay allocated for a while.

solid answer

~50 s

Redis keeps no per-key timer. Expiry is logical first, physical second. - **Logically** the key is gone the instant its deadline passes: any command that looks it up (GET, EXISTS, TTL) treats it as missing. You can never read a value whose TTL elapsed. - **Physically** the entry and its value stay allocated until either a command touches the key and the lookup path deletes it on the spot (lazy expiration), or the background active-expiration cycle randomly samples keys that carry a TTL and reclaims the expired ones. Between deadline and reclaim the key still occupies memory, can still be counted by DBSIZE / INFO keyspace, and used_memory has not dropped. A cold key nobody reads again is reclaimed only by the sampling cycle. INFO stats -> expired_keys counts reclaims that actually happened, and the reclaim is also what propagates a DEL/UNLINK to replicas and the AOF.

code

text · 13 lines
text
127.0.0.1:6379> SET s v EX 5
OK
# ... wait 6 seconds, touch nothing ...
127.0.0.1:6379> DBSIZE
(integer) 1          # still counted: not yet reclaimed
127.0.0.1:6379> TTL s
(integer) -2         # logically gone
127.0.0.1:6379> GET s
(nil)                # this lookup performs the lazy delete
127.0.0.1:6379> DBSIZE
(integer) 0
127.0.0.1:6379> INFO stats
# expired_keys:1

go deeper

for a junior

Recall the core: no timers; delete on access plus a background sampler; reads never return expired data.

for a middle

Add the mechanics: the separate expires table, the fact that the reclaim propagates a DEL to replicas and the AOF, and why lazy alone would leak memory.

for a senior

Frame it as an operational fact: counters and memory lag TTLs, so alerting on dbsize or memory drop timing is unreliable; expiry also costs replication bandwidth.

for a principal

Position it as the single-threaded design tradeoff: exact logical semantics with amortized, bounded-cost reclamation, trading memory promptness for predictable latency.

## Two events, not one Saying a key "expired" conflates two things. The **deadline** is the absolute millisecond timestamp Redis stored when the TTL was set. The **reclaim** is the moment the entry and value are actually freed. Redis makes the deadline exact and lets the reclaim lag. Each database holds two tables: the main keyspace (key -> value) and a smaller *expires* table containing only keys that carry a TTL, mapping key -> deadline. Setting a TTL adds an entry there; PERSIST or overwriting the key with a plain SET removes it. Nothing schedules a callback: there is no timer wheel and no thread per key. That is a deliberate design choice, because Redis serves commands from a single thread and firing millions of timers on it would be ruinous. ## Lazy expiration: delete on access Every command that resolves a key runs a check first. If the key is in the expires table and its deadline is in the past, Redis deletes it right there and then serves the command as if the key never existed. So GET returns nil, EXISTS returns 0, TTL returns -2, and an INCR starts from zero. This is why the guarantee is airtight: the read path itself enforces it, so a stale value can never be served, no matter how late the physical reclaim is. The reclaim is also a write: Redis propagates an explicit DEL (or UNLINK when lazyfree-lazy-expire is enabled) to replicas and to the AOF, and fires the `expired` keyspace notification. Expiry is therefore not free — it produces replication traffic. ## Why lazy alone is not enough If a key is never touched again, lazy expiration never runs, and the key would leak forever. A cache full of one-shot keys would grow without bound. Hence the second mechanism: a background **active expiration cycle** that samples random keys from the expires table on a schedule, deletes the expired ones, and repeats while it keeps finding many. Together the two bound both correctness (lazy) and memory (active). ## What you can still observe Until the reclaim happens: - `used_memory` still includes the key and its value. - `DBSIZE` and `INFO keyspace` counters can still include it, so a database can report more keys than are logically readable. - A keyspace scan may still surface the name, while the very next GET on it returns nil. - The `expired` event has not fired yet, so anything triggered by that event has not run. This is normal and not a bug. It becomes a problem only when a large number of keys expire and nothing ever reads them, so memory stays high until the sampling cycle grinds through them — or, under maxmemory pressure, until the eviction path steps in for a different reason. ## The takeaway to say out loud "Redis never serves an expired value, but it does not free it on the tick. It deletes on access, plus a background sampler; memory and key counts can lag the TTL."

  • Can a client ever read a value whose time-to-live has already elapsed?
    No. Every key lookup checks the deadline before serving, so an expired key is reported as missing even if its memory has not been reclaimed. The only nuance is a replica that has not yet received the master's DEL: it still masks the key on reads, so clients never see the stale value there either.
  • If a key with a TTL is never accessed again, what eventually frees it?
    The active expiration cycle, which repeatedly samples random keys from the table of keys that have a TTL and deletes the expired ones. Under maxmemory pressure the eviction path can also remove it, but that is a different mechanism with different policy rules.

Like a fridge with dated food: the yoghurt is off the moment the date passes (nobody will eat it), but it only leaves the fridge when someone reaches for it or when you do a random shelf sweep.

saying these in an interview costs you the question

  • Claiming Redis sets a timer or callback per key that fires at the TTL
  • Claiming an expired key can still be returned by GET until it is reclaimed
  • Assuming used_memory or DBSIZE drops the instant TTLs elapse
  • Thinking the application must delete expired keys itself
  • Confusing expiration (TTL elapsed) with eviction (maxmemory pressure)

context

open as a page

Besides deleting a key when it is next accessed, Redis runs a background cycle over keys that carry a time-to-live. Walk through how that cycle decides how much work to do, and why its guarantee is probabilistic rather than exact.

level: middleimportance: must knowfreq 52%

basics

~20 s

It repeatedly samples about 20 random keys from the table of keys with a TTL, deletes the expired ones, and loops again if more than 25% of the sample was expired. Time-boxed per run, so it bounds stale keys statistically, not exactly.

open as a page

In a Redis primary-replica setup, how does a key whose time-to-live has elapsed actually get removed on the replica, and what surprising behaviour does that cause when you read from or measure that replica?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Replicas never expire keys on their own. They wait for the primary to send an explicit DEL/UNLINK when it reclaims the key. Meanwhile a replica read masks the key as missing, so its key count and memory can exceed the primary's.

open as a page