Cache Invalidation: TTLs, Explicit Invalidation and Stale-While-Revalidate

How to keep cached data fresh: choosing TTLs, explicit and event-driven invalidation, TTL as a safety net, stale-while-revalidate and TTL jitter.

Advanced 5 min read · Updated

Cache invalidation is how stale entries get removed or refreshed after the source data changes. Caches can hold stale data (see cache consistency); invalidation is the mechanism that limits it. Get it wrong and users see outdated data; make it too aggressive and the cache stops helping, because every request falls through to the database. It's famously one of the hard problems in computing, because caches and databases update separately, concurrently, over unreliable networks.

Time-to-live (TTL)

A TTL attaches a countdown to each entry. When it reaches zero the entry is removed automatically; the next read misses, fetches fresh data from the database, and stores it with a new TTL.

fresh: reads are cache hitsre-cached0:001:002:003:004:005:006:00SET (TTL 5 min)miss → DBexpires
TTL lifecycle: an entry is cached at 0:00 with a 5-minute TTL; reads hit until it expires, the next read misses and re-caches it with a fresh TTL.

TTL is the simplest form of invalidation: no coordination between services is needed, because each entry removes itself on schedule. The cost is staleness: data can be out of date for up to the whole TTL. Choose the TTL by how stale the data can be without hurting users:

DataTypical TTLWhy
Session tokens30 minutesMatch the session timeout policy
Product catalog5–60 minutesPrices change rarely but must update the same day
User profile5–15 minutesChanges are rare; brief staleness is fine
Search results1–5 minutesResults change often; short freshness window
ConfigurationHours to daysRarely changes; a long life saves load
Feature flags30–60 secondsChanges should reach every instance quickly

Too short a TTL defeats caching (the database takes nearly the full load); too long means users see old data. Most teams start around 5–15 minutes and tune from observed staleness complaints and hit rates.

Explicit invalidation

TTL is passive: it waits for time to pass. Explicit invalidation is active: when the data changes, the application deletes (or overwrites) the cache entry immediately. That's the "delete the key" step of write-around. If the delete is synchronous and race-free, staleness is limited to the brief gap between the database write and the cache delete.

The challenge is making sure every write path invalidates. If user data is changed by an API endpoint, a background job and an admin panel, all three must delete the key; miss one and you have a silent staleness bug that is hard to track down.

Event-driven invalidation

Instead of every writer knowing about the cache, the database emits change events (via change data capture or application events). A single subscriber listens and deletes the matching keys. Invalidation logic lives in one place, and no write path can forget it.

DEL keyAPI endpointBackground jobAdmin panelDatabaseChange eventsCDC / binlogInvalidatorCache
Event-driven invalidation: every writer just writes the database; one subscriber turns change events into cache deletes.

TTL as a safety net

Production systems use both. Explicit invalidation handles the normal case: data changes and the cache is cleared at once. The TTL is the safety net: if an invalidation fails (a lost message, a crashed subscriber, a mismatched key name), the stale entry still removes itself when its TTL runs out. The worst case becomes bounded staleness (at most one TTL) rather than unbounded staleness (stale until the next write, which may never come).

Use more than one: delete on write (or events) for freshness, plus a TTL as a safety net, so any missed invalidation heals itself.

Stale-while-revalidate

A plain TTL is binary: fresh (return it) or expired (miss, wait for the database). The moment of expiry causes a latency spike for whichever user arrives first. Stale-while-revalidate softens the edge: when an entry is past its freshness window, the cache returns the stale value immediately and refreshes it in the background. That user gets a fast, slightly stale answer; once the refresh finishes, everyone gets fresh data.

UserCacheRefresherDatabaseGET product:42 (past soft TTL)stale value, instantlytrigger async refreshSELECT fresh rowSET product:42 (new TTL)next GET → fresh value
Stale-while-revalidate: the first request after expiry gets the old value at cache speed while a background refresh fetches the new one.

This largely removes the expiry spike; the trade is that one request after expiry gets stale data, which is invisible for product pages, search results or recommendations. HTTP caches and CDNs support it with the stale-while-revalidate directive in Cache-Control (RFC 5861). Application caches need a little custom logic: keep a soft TTL (when to refresh) shorter than the hard TTL (when to delete), return the value if past the soft TTL, and start an async refresh.

import time, threading

SOFT_TTL, HARD_TTL = 300, 3600                 # refresh after 5 min, keep for 1 h

def get(key, loader):
    entry = cache.get(key)                     # {"value": ..., "fetched_at": ...}
    if entry is None:                          # hard miss: load synchronously
        return _load(key, loader)
    if time.time() - entry["fetched_at"] > SOFT_TTL and cache.set_if_absent(f"{key}:refreshing", 1, ttl=30):
        threading.Thread(target=_load, args=(key, loader), daemon=True).start()   # one refresher only
    return entry["value"]                      # stale-but-fast

def _load(key, loader):
    value = loader()
    cache.set(key, {"value": value, "fetched_at": time.time()}, ttl=HARD_TTL)
    return value

TTL jitter

When many entries get the same TTL (common when a batch of data is cached at once, or after a deploy warms the cache), they all expire at the same instant. The result is a synchronized miss storm: thousands of requests hit the database together and can overwhelm it.

Jitter spreads expirations out randomly. Instead of a fixed 300-second TTL, give each entry a TTL between 270 and 330 seconds (300 ± 10%); expirations are now spread evenly over a minute instead of piling up on one second.

expirations spread out270 s285 s300 s315 s330 sfixed TTL: all expire here
Without jitter (top) a batch cached together expires at one instant; with ±10% jitter (bottom) the same entries expire spread across a minute.
import random

def ttl_with_jitter(base_seconds=300, spread=0.10):
    return int(base_seconds * random.uniform(1 - spread, 1 + spread))   # 270–330 s

cache.set(key, value, ttl=ttl_with_jitter())

Jitter is the same defense used against the cache avalanche, where a whole cache's worth of entries expires together.

Invalidation strategies compared

StrategyHow it worksTrade-off
TTL expiryEntries expire after a fixed timeSimple and self-healing; stale for up to the TTL
Delete on writeAfter updating the database, delete the keyFresh quickly; can be missed or raced (see consistency)
Update on writeAfter updating the database, write the new value to the cacheNo miss after writes; out-of-order updates can leave wrong values
Event-driven (CDC)A subscriber deletes keys from the database's change streamCan't be forgotten by any writer; small delay, more infrastructure
Versioned keysBump a version in the key; old entries are never readNo deletes needed; old entries use memory until evicted. See key design
Stale-while-revalidateServe stale past a soft TTL while refreshing in the backgroundNo expiry spikes; one slightly stale response

Invalidating many caches

With in-process caches on hundreds of servers, a single delete isn't enough. Broadcast invalidations over pub/sub (Redis pub/sub, Kafka) or keep in-process TTLs very short. For CDNs, prefer versioned URLs and use purge APIs (by URL or tag) only when you must. See CDN.

Where it’s used

Facebook memcache

Invalidates from the database's commit log and uses leases so a slow reader can't refill a just-deleted key with stale data.

CDNs (Fastly, Cloudflare)

Support stale-while-revalidate, tag-based purges and instant invalidation APIs.

Feature-flag systems

Short TTLs (30–60 s) plus push notifications so flag changes reach every instance quickly.

E-commerce catalogs

Product entries cached 5–60 minutes with jittered TTLs, explicitly deleted when a merchant edits a product.

In an interview

  • Give every cached type a TTL chosen from its staleness tolerance, with jitter.
  • Add explicit or event-driven invalidation for data that must update quickly, with the TTL as a safety net.
  • Mention stale-while-revalidate for hot keys where expiry spikes would hurt latency.
  • For many app servers with local caches, broadcast invalidations or keep local TTLs short.