How Stale Can You Afford to Be: TTL as an Explicit Consistency Contract
A user changes the email address on their account, reloads the page, and the old address is still there for four minutes. Nothing crashed — your caches each did exactly what their TTLs said. The bug is that nobody ever decided how stale "stale" is allowed to be for this field; the TTL was inherited from a copy-pasted snippet.
Time-to-live is the one invalidation mechanism that always works: no publisher, no fan-out, no lost messages. It converts a correctness problem into a stated staleness budget, and the discipline is to set that budget per data class instead of per codebase:
| Data | Max acceptable staleness | Typical TTL | If you need better than TTL |
|---|---|---|---|
| Auth/session validity | Seconds (revocation) | 30–300 s or no cache | Write-through + direct DEL |
| Product price / stock | 0–5 s | 5–30 s | Invalidate on write, version check on read |
| Catalog metadata (title, images) | 5 min | 300 s | TTL alone is usually fine |
| Feed / timeline / recommendations | 30–60 s | 30–60 s | TTL; serve-stale during refresh |
| Analytics counters / dashboards | 5 min | 300 s | Nothing — staleness is the product |
| Feature flags | 10–15 s | short TTL | Push invalidation; fallback on flag |
Two structural facts make this harder than one table suggests.
First: worst-case staleness is the sum of every layer's TTL in the request path. A read can pass through an in-process L1 (5 s), a shared Redis L2 (300 s), and a CDN in front (60 s). A value refreshed at each layer can be up to 5 + 300 + 60 = 365 s behind, not 5 seconds. When you document a staleness SLO, walk the whole path and add the budgets — the layer you forgot is usually the one that answers the support ticket.
Second: TTL cannot give you read-your-writes. The user's own write followed by their own read is the case where staleness is most visible and least acceptable, and no TTL tuning fixes it because the read races the very cache you just wrote through. Standard answers, cheapest first: (a) bypass the cache for a short window after the user's write (sticky "fresh read" flag), (b) return the written value in the write response and render it directly, (c) attach a version or updated_at to the client and treat mismatched versions as an automatic cache bypass.
Write-through does not rescue the other layers either: your shared cache may hold the new value while the CDN in front of it serves the old one for another 60 seconds. This is the sense in which TTL bounds staleness but can never guarantee immediate correctness after a write — every guarantee beyond the TTL requires active invalidation, and active invalidation is where the trade-offs live.
Frame it this way in an interview: nobody buys "strong consistency everywhere" — the price in latency, fan-out, and availability is prohibitive — so you choose the staleness contract per data class, you make the worst case (sum of layers) explicit, and you reserve active invalidation for the small set of fields where seconds matter. Expect a follow-up: "how would you prove your staleness bound?" The answer is a synthetic check — write a sentinel value, read it through the full path on a loop, and assert the observed lag stays under budget. If you cannot measure the bound, you do not have one. That set of seconds-critical fields is exactly what the next two chapters optimize.
Event-Driven Eviction: Invalidate After Commit and Survive Lost Messages
TTL of five minutes is too slow for a price change, so the writer publishes "price for SKU 42 changed" the moment it commits, every application node deletes its local copy, and the next read reloads fresh data. Convergence in ~50 milliseconds instead of 300 seconds — this is event-driven invalidation, and its failure modes are instructive because three separate things must go right.
1. Publish strictly after the database commit. The canonical bug: emit the invalidation, then commit (or emit outside the transaction). A reader on another node re-fetches before the commit lands, repopulates the old value, and your invalidation has now re-seeded the stale entry — which then lives out its full TTL. The robust versions:
naive: UPDATE ... ; publish(invalidate) ; COMMIT ← race: repopulate old value
correct: BEGIN; UPDATE ...; INSERT INTO outbox(...); COMMIT
→ outbox relay publishes after COMMIT (transactional outbox)
→ or publish an UPDATE-propagation event carrying the new value
The transactional outbox (a row written in the same transaction, published by a relay or CDC) closes both gaps — "committed but not published" and "published but not committed" — and is the pattern most interviewers accept as the correct answer.
2. Assume at-least-once delivery and design for duplicates. Delete is idempotent: DEL user:42 twice is harmless. That is precisely why invalidate events are safer than set events — a "here is the new value" message can arrive out of order (new value, then a delayed old value) unless it carries a monotonic version the receiver compares. And some mechanisms are best-effort by design: Redis keyspace notifications and similar pub/sub hooks may silently drop messages during failover. The TTL stays on every key as the correctness backstop; the event only shortens the typical case. Belt and braces, and the belt is cheap.
3. Close the repopulation race. The nastiest interleaving needs no lost message at all:
reader: MISS → read DB (sees price 9.00, pre-update) ... context switch ...
writer: commit price 10.00 → publish invalidation → cache now clean
reader: SET product:42 = {"price": 9.00} ← stale value resurrected
Mitigations, pick by stakes: always set with a TTL (bounds the damage); carry updated_at in the value and reject a SET whose timestamp is older than what is stored (a small Lua/CAS compare); or use versioned keys, which eliminate the race by construction — chapter three.
Fan-out economics decide scalability. One event → N in-process caches is fine at N = 50. Trouble appears when invalidation granularity is fine: one entity update touching price, stock, reviews, images × 3 locales × 2 device classes is 48 keys, and CDN purge APIs bill or rate-limit beyond a few thousand paths per request. Keep invalidation units coarse — invalidate the entity and let the reader rebuild its projections — and maintain tag→keys indexes (tag:sku42 → {keys}) instead of enumerating keys by hand.
| Mechanism | Converges in | Guarantee | Failure mode |
|---|---|---|---|
| TTL only | Up to TTL (seconds–minutes) | Always eventually correct | Stale reads up to budget |
| Event-driven eviction | ~50–500 ms typical | Fast, but delivery not guaranteed | Lost message → stale until TTL |
| Outbox + at-least-once | Same, after commit | No lost invalidations | Needs relay/CDC; duplicates OK for deletes |
| Value propagation (update event) | Same | Fresh value directly | Needs versions to beat reordering |
The design sentence to remember: events buy you speed, TTL buys you correctness, and the outbox is what makes the fast path trustworthy. When even that leaves a delete race you cannot close, stop deleting — make old entries unreachable instead.
Versioned Keys, Tags, and Soft Purges: Making Old Entries Unreachable
Deleting cache keys is a coordination problem: between your DEL and the next reader's SET, a stale value can slip in, and across three cache layers plus a CDN, "delete everywhere" is a fan-out you must get right every time. Versioned keys change the shape of the problem — instead of removing old data, you make it unreachable, and correctness becomes a property of naming.
Per-entity versions. Give each key a version that moves forward with the data:
user:17:v83:name ← value written when user 17 was updated at version 83
config:v42:feature-flags ← bumped when the config service publishes v42
On write, you write user:17:v84:… — nobody ever reads v83 again, so nobody can be served it. No delete, no race window, and read-your-writes falls out for free: the client that triggered update v84 reads only v84 keys. Where does the version come from? A config/feature version for whole-keyspace resets (schema changes, pricing-rule rollouts), or the entity's own updated_at/monotonic version for per-row freshness.
The costs are real and worth stating before you adopt it:
- Cold misses on bump. A global version bump makes every key miss at once — a thundering herd you scheduled. Ramp it: pre-warm the hottest keys before flipping the pointer, or publish the new version to 5% of nodes first.
- Double memory during the ramp. Old and new entries coexist until TTL expires the old ones. Size the cache for 2× during bumps, or run explicit cleanup with
SCAN-based deletion throttled to a few hundred keys/second. - Key churn. Per-entity versions on hot-update rows (counters!) rewrite the keyspace constantly and can fragment memory. Use versions for correctness-critical freshness, not for every write.
Tag-based invalidation handles "one row, many keys" without enumerating paths. Keys register under tags (sku:42 tags → price, stock, reviews, image-webp), and one invalidate tag:sku:42 deletes the whole group — Varnish's BAN/softpurge, Memcached's delete-by-group idiom, Redis with a SET tag:sku:42 member1 member2 … index cleaned up after deletion. The known race: a key added to the tag set after the tag was purged survives with a stale value — close it by having writers bump the entity version (write the new key under the new version) or by re-checking the tag generation on insert. Tags reduce fan-out from dozens of paths to one call; they do not remove the need for a TTL backstop.
Soft purge (mark-stale, not delete) is the latency-conscious variant: the entry is flagged invalid — future requests revalidate it — while concurrent requests are still served the stale copy during revalidation. Combined with stale-while-revalidate from CDN and Edge Caching, a soft purge gives you immediate freshness semantics without the origin burst a hard purge causes.
Probabilistic early expiration handles the expiry-time stampede without any event at all: on each read, recompute early with probability rand() < C × (age / ttl) (XFETCH, C ≈ 1), so entries are refreshed one at a time, jittered, before the synchronized expiry instant — the invalidation problem solved by never letting the herd arrive together.
Choose by stakes: TTL alone for data where the budget is honest; events + outbox when seconds matter and the key space is small; versioned keys when correctness must not depend on a delete succeeding; tags when one entity fans out to many keys. Whatever you choose, keep a non-zero TTL everywhere — it is the backstop that converts "we missed an invalidation" from a data-incident into a five-second delay. With caching now placed, keyed, and invalidated, the next topic turns to what happens when the systems behind the cache start failing: Timeouts and Retries.
Quiz
1. A read passes through an in-process L1 (TTL 5 s), a shared Redis L2 (TTL 300 s), and a CDN (TTL 60 s). What is the worst-case staleness of a value after a write?
2. A service publishes "price changed" invalidations, yet users occasionally see the old price for a full TTL afterward. Which design flaw produces this?
3. What happens on a cache when a config version bumps from v41 to v42 if keys are versioned?
4. A reader loads a value, a writer commits and invalidates, and the reader then SETs its stale copy into the cache. Which technique prevents the resurrected value without any distributed lock?
Flashcards
Question
What staleness contract does a TTL provide, and what does it never provide?
Click to reveal answer
Answer
It bounds staleness for a key: at worst one TTL period after the write, the value is reloaded — with no infrastructure beyond a clock. It never provides read-your-writes or immediate correctness after a write, and across layers the bound is the sum of TTLs in the path (5 s L1 + 300 s L2 + 60 s edge ≈ 365 s worst case), which is the number to document.
Question
Why must invalidation events be published after commit, and what pattern guarantees it?
Click to reveal answer
Answer
Publish before commit and a reader can fetch the pre-write row after the delete, re-seeding stale data that then lives out its full TTL. The transactional outbox guarantees ordering: write the event row inside the same transaction, publish from a relay or CDC after COMMIT — closing both "committed but not published" and "published but not committed".
Question
What delivery guarantee should invalidation events assume, and what is the safety net?
Click to reveal answer
Answer
At-least-once: deletes are idempotent so duplicates are free, value-carrying events need monotonic versions to survive reordering, and best-effort channels (Redis keyspace notifications) can drop messages entirely. The safety net is a non-zero TTL on every key — a missed invalidation then costs seconds, not a data incident.
Question
How do versioned cache keys make stale entries unreachable, and what do they cost?
Click to reveal answer
Answer
Key = entity or config version + field: after a bump, readers only construct new-version keys, so old entries cannot be read — no delete, no repopulation race, read-your-writes by construction. Costs: every key misses at once on bump (pre-warm or ramp to avoid a scheduled stampede), old and new coexist until TTL drains them (size for ~2x), and hot-updating rows churn keys.
Question
Compare convergence and cost: TTL-only, event-driven eviction, and tag invalidation.
Click to reveal answer
Answer
TTL-only: converges within seconds–minutes, zero infrastructure, always eventually correct. Event-driven eviction: ~50–500 ms, needs publisher + subscribers and a TTL backstop for lost messages. Tag invalidation: one call fans out to many keys via a tag→keys index — great for entity-level changes, but writers racing onto a freshly purged tag set need version checks, and CDN purge quotas make coarse granularity mandatory.
Revision Notes
Key Takeaways
- 1. TTL converts correctness into an explicit staleness budget per data class — and worst-case staleness across a path is the sum of every layer's TTL, not the smallest one
- 2. Active invalidation must publish strictly after commit (transactional outbox) or a racing reader re-seeds the pre-write value for a full TTL
- 3. Assume at-least-once: deletes are idempotent, value events need versions, best-effort channels lose messages — which is why a non-zero TTL stays on every key
- 4. Versioned keys sidestep deletion races by construction: old entries become unreachable instead of being deleted, at the cost of a cold ramp and temporary double memory
- 5. Fan-out economics decide the design — coarse entity-level invalidation and tag indexes beat enumerating dozens of keys, and purge APIs punish fine-grained CDN invalidation
Interview Tips
- • "How stale can your cache be?" — answer with numbers per data class, then compute the sum across L1/L2/CDN; naming the sum shows you have operated a multi-layer path, not a single Redis
- • "A user changed their email and still sees the old one." — walk the layers, identify the TTL that answered, then give the options: bypass-after-write, render the write response, or version-checked read
- • "How do you invalidate cache after a database update?" — commit-then-delete for the single key, transactional outbox + at-least-once for cross-node fan-out, TTL as the backstop, and versioned keys if a delete race is unacceptable
- • "One product update touches 48 cache keys plus CDN paths — how do you scale invalidation?" — tag→keys index for coarse fan-out, soft purge + stale-while-revalidate at the edge, and a memory plan for version bumps (pre-warm, ramp, throttled SCAN cleanup)
Cheat Sheet
Cache Invalidation Trade-offs
Pick the mechanism from stakes and convergence time:
| Mechanism | Converges in | Guarantee | Cost / failure mode |
|---|---|---|---|
| TTL only | Up to TTL | Always eventually correct | Staleness = budget; worst case = sum of layer TTLs |
| Event-driven eviction | ~50–500 ms | Fast; delivery not guaranteed | Lost message → stale until TTL |
| Outbox + at-least-once | Same, post-commit | No lost invalidations | Relay/CDC to operate; deletes idempotent |
| Tag invalidation | Same | One call → many keys | Tag/index races; purge path quotas |
| Versioned keys | Immediate for new reads | No delete race at all | Cold misses + ~2x memory on bump |
Write + invalidate sequence:
BEGIN; UPDATE ...; INSERT INTO outbox(event); COMMIT ← event rides the same txn
relay publishes AFTER commit → all nodes DEL key ← at-least-once, DEL idempotent
reader late SET rejected by: SET if incoming.version >= stored.version (Lua/CAS)
Versioned keys: config:v42:flags, user:17:v84:name — bump = old keys unreachable, TTL drains them. Pre-warm hot keys and ramp the version before a global bump.
Expiry hygiene: jitter expire = base + random(0, 0.2 x base); early refresh rand() < C x (age/ttl), C ≈ 1; soft-purge + stale-while-revalidate = fresh semantics without the origin burst.
The floor: every key keeps a non-zero TTL — it converts a missed invalidation from an incident into a delay.