Backend Systems · Caching

TTL Expiry and the Two Ways an Entry Dies

How deadline-based caching separates logical expiry from physical reclamation, why lazy and active expiry put the cost in different places, and how clocks and replicas make a TTL a bound rather than a promise.

A time-to-live looks like the simplest control in a cache: attach a deadline, and the entry stops being served afterwards. Implementations are more interesting than that, because a deadline creates two separate events that can happen at different times. The entry becomes logically invalid at the deadline, and the memory it occupies is reclaimed at some later moment chosen by the cache. Conflating the two produces systems that either leak memory or block readers, so every serious cache separates them and pays for the separation somewhere.

Expiry is not deletion

Redis states the model plainly in its own documentation: keys expire logically when their deadline passes, and the memory is freed later, either when the key is touched or during a periodic sweep. The observable behaviour — a GET after the deadline returns nothing — is the same either way. The resource behaviour is not. Between the deadline and the reclamation, the entry still occupies its slot in the hash table, still counts toward the memory limit, and still competes for the same eviction budget as live entries.

This matters because the two events have different costs. Enforcing the deadline requires comparing a stored timestamp against the current time, which is a handful of instructions. Reclaiming memory requires removing a node from a hash table, possibly freeing a slab allocation, and possibly unlinking from an LRU list. Doing the second on every deadline is expensive if deadlines cluster; doing it lazily leaves garbage that a memory-limited cache must account for.

A cache with a hard memory ceiling therefore has to decide what an expired-but-not-reclaimed entry means for capacity. If expired entries still count, a workload with short TTLs and skewed access effectively shrinks the usable cache, because entries nobody reads again sit in memory until swept. If they do not count, the accounting must find them, which is the sweep itself.

Lazy expiry: the cost moves to the reader

In lazy expiry, the check happens on access. A reader arrives, the key is found, its deadline is compared to the clock, and if the deadline has passed the entry is treated as absent — typically deleted at that moment, and the miss path proceeds.

The advantage is perfect economy: an entry that is never read again is never examined again, and no background work competes with request handling. The disadvantage is that expiration is bounded by access, not by time. A key written once and never read holds memory indefinitely as far as the read path is concerned. Under a workload with a long tail of one-shot keys, lazy expiry alone degenerates into a cache that fills with unreachable entries and evicts live ones to make room.

Lazy expiry also puts the deletion inside the read’s critical path. In a single-threaded server that is one hash-table removal between the lookup and the reply, which is cheap but not free. In a sharded, threaded store it interacts with whatever lock guards the bucket.

A subtle consequence: a lazy cache that reports hit ratio on the read path sees the expired entry as a miss, so the miss rate includes entries that died of age. That is the correct accounting for the application, and the wrong accounting for capacity planning, which wants to know how much memory was actually reclaimable.

Active expiry: a scavenger with a budget

Active expiry runs a background process that samples keys with deadlines and removes those that have passed. Redis describes its approach as a periodic sample of the keys carrying an expiry, with the sweep repeating while the proportion of expired keys in the sample exceeds a threshold, bounded per cycle so that a cache full of expired keys cannot monopolise the server.

Two design parameters dominate. The first is sample size: sampling a fixed number of keys and extrapolating is much cheaper than walking the whole expiry index, but it can miss a sparse scatter of expired keys indefinitely. The second is the work budget per cycle: the sweep must stop even when the sample says more work is available, or a burst of simultaneous deadlines turns into a latency spike that looks exactly like a stall to clients.

memcached takes a different route. Expiration is lazy — an item past its deadline is treated as absent when looked up — and reclamation of never-touched items is left to the LRU machinery. The classic memcached design keeps a per-slab-class LRU list and allocates by overwriting the least recently used item in the class, so an expired item that is never touched is eventually pushed off by allocation pressure rather than by a timer. Newer versions added a background crawler that reclaims expired items ahead of allocation. The difference in mechanism produces a difference in behaviour: with pure lazy-plus-LRU, a slab class whose items are all expired but untouched can still hold memory that another class cannot use, because slab pages are not shared across classes once assigned.

The general lesson is that active expiry is a latency-versus-memory trade. More background work buys back memory sooner and risks interfering with request latency; less buys steadier latency and a larger effective memory footprint.

TTL, TTI, and what the bound actually is

Two expiry policies are commonly confused. Time-to-live counts from the write and does not move; time-to-idle counts from the last access and is reset on every read. TTI expresses “keep what is hot”, TTL expresses “do not serve anything older than this”. For correctness-sensitive data only TTL gives a staleness bound; TTI can keep a value alive forever if it is read frequently, which is the opposite of what a cache in front of a mutable source wants.

The bound is also one-sided. A TTL of five minutes guarantees that a stale value cannot be served for more than five minutes after the write that produced it — provided the entry was written at the same moment as the source mutation, which is exactly the assumption that fails. A write that does not invalidate leaves the old entry to live out its full TTL. That is why TTLs are a safety net for invalidation bugs rather than a substitute for invalidation, as discussed under invalidation by deletion and versioned keys.

Choosing the number is a cost question, not a correctness question. Shorter TTLs mean more misses and therefore more source load; longer TTLs mean more stale reads and a larger blast radius when invalidation fails. A useful exercise is to compute the source load at a given miss rate: if the source can serve N reads per second and the application issues R, the cache must absorb at least 1 − N/R of them, and every TTL shortening moves the operating point.

Clocks, replicas, and expiry that arrives early

A deadline is stored as an absolute timestamp, which makes it sensitive to clock behaviour. Redis converts a relative TTL to an absolute time on the server and compares against the server’s clock, so a backwards step in wall-clock time extends the life of every entry written before the step. Systems that store the deadline on the client and compare on the server inherit both clocks’ disagreement.

Replication turns one deadline into several. A read served by a replica applies the replica’s clock to a timestamp written on the primary, so skew becomes skew in expiry. Worse, replication itself can deliver the expiry late: a key that expires on the primary produces an ordinary deletion, and a replica that has not received it still holds the entry. Redis handles this by not letting replicas expire keys independently — the replica waits for the primary’s deletion — while checking the logical deadline on reads, so a key past its deadline is reported missing even though its memory has not yet been reclaimed. The window that replication lag opens is therefore one of memory occupancy, not of answers served.

The same problem appears with caches that store a deadline but are read through multiple application hosts. Any host whose clock runs fast evicts entries early; any host whose clock runs slow serves them past their deadline. For a strict bound, the deadline comparison has to happen in one clock domain, or the TTL has to be set with the maximum skew as slack.

What a TTL cannot fix

Expiry addresses staleness of a single key. It does not address a set of keys that must be consistent with each other: if a cached user and a cached membership list expire on different schedules, readers observe combinations that never existed in the source. It does not address the source being unavailable at the moment of expiry, which turns a deadline into a spike of load against a system already in trouble — the mechanism behind the stampede discussed in request coalescing and probabilistic early expiration. And it does not address the case where the correct answer is “this key does not exist”, which needs a negative entry with an expiry of its own rather than the absence of an entry.

What expiry does provide is a hard ceiling on how long any single cached fact can survive without revalidation, and that ceiling is the only thing standing between a missed invalidation and permanent divergence.

References

  1. Redis documentation — EXPIRE — Redis project
  2. Redis documentation — Key eviction — Redis project
  3. Redis documentation — Replication — Redis project
  4. memcached documentation — Expiration — memcached project