TTL and Expiry
A TTL is a staleness budget written as a number, plus the only invalidation mechanism that cannot fail.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind has a problem.
How long should an entry live, and why is a round number almost always the wrong answer?
Cached data should be fresh enough that nobody complains, and cached long enough that the database stays quiet.
Set the TTL to five minutes. It is a reasonable-sounding number and it is what the example in the documentation used.
Five minutes is either far too long for a price or far too short for a country list, and using it for both means one of them is wrong all the time.
- Five minutes is either far too long for a price or far too short for a country list, and using it for both means one of them is wrong all the time.
- Everything populated during a deploy or a cache flush expires within the same second five minutes later, producing a synchronised miss wave that repeats — a self-inflicted load spike on a fixed cycle (Cache Stampede).
- A TTL is not the only reason an entry disappears. Under memory pressure the cache evicts entries early, so the actual lifetime of a key is the minimum of the TTL and whatever eviction decides — and the code that assumed five minutes has no idea.
- Nobody can say where the number came from. When someone asks whether it can be raised, the honest answer is that no one knows what it was chosen to bound.
What is actually happening
- A TTL answers one question: for how long am I willing to serve a value that may already be wrong? It is a product decision expressed as a number, not a tuning parameter.
- It is the only invalidation mechanism with no operation that can fail. Nothing has to be delivered, nothing has to be retried, and it works for writers you have never heard of. That is why it belongs underneath every other strategy (Cache Invalidation).
- Expiry and eviction are different things. Expiry is time-based and per-key. Eviction happens when the cache is full and must reclaim memory — LRU, LFU, random, or "refuse the write" depending on configuration. An entry can vanish long before its TTL (Cache Replacement: LRU Is the Idea, Not the Implementation).
- Fixed TTLs synchronise. Entries created together expire together, and any event that populates many keys at once — a deploy, a restart, a bulk invalidation, a cold start — creates a cohort that will miss in unison forever after. Jitter breaks the cohort: pick the TTL from a range rather than a constant.
- Stale-while-revalidate decouples "stop serving this" from "refresh this". The entry carries a soft deadline and a hard one: after the soft deadline the value is still served while a background refresh runs; only after the hard deadline does a request block on the loader.
Pick the number from the data, not from the example
The useful question is not "how long is safe" but "who notices, how quickly, and what does it cost them". A stale country list is invisible. A stale price is a support ticket. A stale permission is a security finding. Those are three different numbers and no single default serves them.
Note that two rows below say "do not cache the value". That is a legitimate outcome of this decision, not a failure to complete it (When Not to Cache).
Who is harmed by a stale value, and how fast?
when Country lists, currency codes, static config, compiled templates.
cost Hours or the process lifetime. Nearly free; remember a deploy must be able to clear it.
when Product copy, CMS content, pricing tables.
cost Seconds to a couple of minutes, plus explicit invalidation so the editor sees their change immediately.
when Dashboards, counts, report rollups, search facets.
cost Minutes, with stale-while-revalidate so nobody ever waits for the recompute.
when Permission sets, role membership, token introspection results.
cost Short, and bounded by your revocation SLA. Never extend on read (Where Sessions Live).
when Balances, inventory at checkout, anything a user can be charged for.
cost Do not cache the value. Cache the immutable context around it and read the number fresh.
when Per-user one-off queries, unique search strings.
cost Any TTL is wasted memory — the entry will never be read again.
Jitter, because synchronised expiry is self-inflicted
A fixed TTL turns every mass-population event into a permanent cohort. Deploy at noon, populate ten thousand keys, and at noon plus five minutes all ten thousand expire in the same second — then again five minutes later, and again, because they are repopulated together too. The load pattern is a sawtooth that never damps out.
Jitter is one line and it removes the cohort permanently. The same trick appears in retry backoff for the same reason: independent clients doing the same thing on the same schedule is what turns load into a spike (Backoff and Jitter).
await cache.set(key, value, 300) // every key populated in the same second // expires in the same second, forever
const base = 300
const ttl = Math.floor(base * (0.8 + Math.random() * 0.4)) // 240..360
await cache.set(key, JSON.stringify({
data: value,
softExpiresAt: Date.now() + ttl * 0.5 * 1000, // refresh in the background after this
}), ttl)Jitter spreads a cohort of simultaneous expiries across a window, so the origin sees a smooth trickle of repopulation instead of a periodic wall of misses. The soft deadline goes further: past it the value is still served while one coalesced refresh runs, so a user never waits for the recompute at all.
Expiry is not the only way an entry dies
Code that reasons about caching in terms of TTL alone is reasoning about one of five ways a key can disappear. The others are not exotic — a memory limit, a failover and a deploy are ordinary Tuesday events, and each of them ends entries early and in bulk.
The practical consequence is a rule: never let correctness depend on an entry being present. A cache is a hint. Any code path that breaks when the value is absent has a bug that will surface during the least convenient event on this list.
| How the entry ends | What triggers it | How many at once | What you see |
|---|---|---|---|
| TTL expiry | Time | A cohort, if TTLs are unjittered | A periodic sawtooth in miss rate |
| Eviction | Memory limit reached | Continuous, whatever the policy picks | Hit rate falls with no config change (Cache Replacement: LRU Is the Idea, Not the Implementation) |
| Explicit delete | Your invalidation path | One key, or many on a bulk write | A delete spike followed by an origin spike |
| Flush / restart / failover | An operator, a crash, a node promotion | Everything | Total cold start — the worst stampede case (Cache Stampede) |
| Deploy of the value shape | A new version tag | Every entry of that type | Miss rate jumps to 100% for one prefix |
How to build it
Most important first.
- Derive the TTL from a sentence a product owner would agree to: "a category listing may be up to two minutes stale". Write that sentence in the code as a comment next to the constant.
- Always add jitter — a TTL drawn from, say, 80–120% of the nominal value. It costs one line and removes an entire class of synchronised-expiry incidents (Backoff and Jitter).
- Give different data classes different TTLs. Reference data that changes by deploy can live for hours; a user-facing counter should live for seconds or not be cached at all.
- Use stale-while-revalidate for anything expensive to compute where slightly-old is acceptable. It converts a latency spike on expiry into a background refresh nobody sees.
- Make the TTL configurable at runtime for the handful of keys that matter, so an incident can be mitigated by shortening a window without a deploy (Feature Flags: Rollout, Kill Switches and Debt).
- Set negative-result TTLs separately and much shorter. "This does not exist" becoming wrong is far more disruptive than a stale value becoming slightly older.
What can go wrong
- Synchronised expiry: a cohort of keys created together expires together, forever, at the TTL interval.
- Eviction under memory pressure making the configured TTL irrelevant, with hit rate dropping and no configuration having changed (Leak or Unbounded Cache? The Question That Picks the Fix).
- A TTL of zero or a missing TTL argument, so the entry never expires and a missed invalidation is permanent.
- Stale-while-revalidate whose background refresh fails silently, so the "stale" value is served indefinitely past its soft deadline with nothing indicating the refresher is dead.
- Refresh-ahead that fires for every key regardless of whether anyone is reading it, turning a cache into a scheduled full-table scan.
- Clock skew between instances making relative TTLs and absolute expiry timestamps disagree about when an entry died.
- A read and an expiry racing: the entry passes its deadline between the lookup and the use, so a large cohort of concurrent readers all discover the miss at once (Cache Stampede).
- Stale-while-revalidate with an uncoalesced refresh: every reader after the soft deadline starts its own background refresh, so a single expiry produces N loader runs.
- Two writers setting different TTLs on the same key, so its lifetime depends on which write landed last rather than on any decision anyone made.
- Absolute-expiry timestamps written by instances with skewed clocks, producing entries that are already expired when they are written.
- Authorization and session data must have a TTL no longer than your revocation requirement. A cached permission set with a one-hour TTL means a revoked admin keeps admin for up to an hour (Role-Based Access Control).
- Do not extend an entry's life on read (sliding expiry) for anything security-relevant. A frequently-used stale permission set never expires at all.
- Negative caching of authorization decisions is safer than positive caching: a cached "denied" that is stale fails closed; a cached "allowed" that is stale fails open (Defence in Depth).
- TTLs are also a data-retention control. Personal data sitting in a cache with no expiry is personal data you are storing in a place your retention policy has probably never looked at.
- "The TTL bounds how stale my data can be." It bounds it only if nothing else populates the key. A stale populate immediately after a write starts a fresh TTL from a value that was already old (Cache-Aside).
- "A short TTL is safer." A short TTL on an expensive loader is a scheduled load spike. Safety comes from the invalidation path; the TTL is the backstop.
- "Entries live for the TTL." They live for at most the TTL. Eviction, failover, a restart, or a flush can end them at any moment, and code that depends on presence is wrong.
- "Sliding expiry keeps hot data fresh." It keeps hot data *resident*. Hot and fresh are opposite outcomes here: the more often a key is read, the longer a stale value survives.
Operating it
- Distribution of served-value age, not just the configured TTL. That distribution tells you what staleness users actually experience.
- Expiry rate versus eviction rate as separate counters. Confusing the two is why "we set a five-minute TTL" and "the hit rate is 30%" coexist.
- Miss rate over time. A sawtooth with a period equal to your TTL is the signature of synchronised expiry, and it is visible on any per-minute chart (A 95% Hit Rate Tells You Almost Nothing).
- For stale-while-revalidate: count background refreshes started, succeeded and failed. A silent refresher failure is invisible in every other signal.
- Memory used versus the configured limit, with the eviction policy recorded in your runbook. "Which policy is this cache running" should not be a question during an incident.
- At 10x traffic on the same key set, a longer TTL becomes more valuable and cheaper: more reads amortise each populate.
- At 10x key count, memory becomes the binding constraint and eviction starts making the TTL decision for you. The fix is a smaller value or a smaller key space, not a longer TTL.
- At high scale, synchronised expiry stops being a latency blip and becomes an outage: enough simultaneous misses on an expensive loader will exhaust the connection pool (Connection Pool Exhaustion).
- Stale-while-revalidate scales unusually well because the refresh count is bounded by the key count rather than by the request rate — provided the refresh is coalesced (Request Coalescing).
- A longer TTL means fewer origin queries and more staleness. There is no setting that improves both; the number *is* the trade.
- Jitter makes expiry unpredictable, which is the point, and it means two keys populated together can now diverge — occasionally visible as two parts of one page disagreeing.
- Stale-while-revalidate removes the latency spike and adds a second state (soft-expired) that both the code and the person debugging it must reason about.
- Runtime-configurable TTLs are the right lever in an incident and one more piece of configuration that can be wrong at 3am (Configuration: Separating Code From Environment).
Where this applies
Backend advice is context-sensitive. These labels say what each claim is specific to, and where a different stack or scale would differ.
- GENERALExpiry as a staleness budget, and the distinction from eviction, hold for every cache.
- SIMPLIFIEDPresents expiry as exact. Real caches expire lazily — an entry is often removed when it is next touched, or by a sampling sweep, so memory is not reclaimed at the instant of expiry and "expired" and "gone" are different times.
- CLOUD-SPECIFICEviction policy is a per-deployment setting, and the default differs by product and by managed offering: some default to evicting the least-recently-used key, some to evicting only keys that carry a TTL, and some to rejecting writes when memory is full. Which one you get changes whether a full cache degrades or errors.
Where the depth lives
This domain teaches the application-side mechanism and hands the rest off.