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.
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.
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:
| Data | Typical TTL | Why |
|---|---|---|
| Session tokens | 30 minutes | Match the session timeout policy |
| Product catalog | 5–60 minutes | Prices change rarely but must update the same day |
| User profile | 5–15 minutes | Changes are rare; brief staleness is fine |
| Search results | 1–5 minutes | Results change often; short freshness window |
| Configuration | Hours to days | Rarely changes; a long life saves load |
| Feature flags | 30–60 seconds | Changes 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.
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.
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 valueTTL 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.
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
| Strategy | How it works | Trade-off |
|---|---|---|
| TTL expiry | Entries expire after a fixed time | Simple and self-healing; stale for up to the TTL |
| Delete on write | After updating the database, delete the key | Fresh quickly; can be missed or raced (see consistency) |
| Update on write | After updating the database, write the new value to the cache | No miss after writes; out-of-order updates can leave wrong values |
| Event-driven (CDC) | A subscriber deletes keys from the database's change stream | Can't be forgotten by any writer; small delay, more infrastructure |
| Versioned keys | Bump a version in the key; old entries are never read | No deletes needed; old entries use memory until evicted. See key design |
| Stale-while-revalidate | Serve stale past a soft TTL while refreshing in the background | No 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
Invalidates from the database's commit log and uses leases so a slow reader can't refill a just-deleted key with stale data.
Support stale-while-revalidate, tag-based purges and instant invalidation APIs.
Short TTLs (30–60 s) plus push notifications so flag changes reach every instance quickly.
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.