# TTL (Time to Live)

## 1. What a TTL Is

A **TTL** is a deadline attached to a cache entry: after it passes, the entry is treated as absent. It's the simplest invalidation strategy (`05-cache_invalidation/`) and the safety net under every other one.

```ts
await redis.set("product:42", json, "EX", 300);     // expire in 300 s
await redis.expire("session:abc", 1800);            // set/refresh a TTL on an existing key
await redis.ttl("product:42");                      // seconds remaining, -1 = no TTL, -2 = missing
```

A TTL answers one question: **how stale am I willing to let this data be?** That's a product decision, not just a technical one.

## 2. Choosing a TTL

| Data | Typical TTL | Reasoning |
|---|---|---|
| exchange rates, stock tickers | seconds | value changes constantly; stale = wrong |
| product details, prices | minutes | edits are rare; explicit invalidation + TTL as backstop |
| user profile, avatars | 5–60 min | small staleness is harmless |
| configuration, feature flags | 30–60 s | changes must propagate reasonably fast |
| static assets with hashed names | 1 year (`immutable`) | the URL changes when content changes |
| session data | sliding 30 min | expires with inactivity |
| negative results ("not found") | short (10–60 s) | prevent a new item looking missing for long |

Rules of thumb: the TTL should be **long enough to get a good hit ratio** and **short enough that staleness is acceptable if an invalidation is missed**. If you can't tolerate any staleness, a TTL alone is the wrong tool.

## 3. Absolute vs Sliding Expiration

- **Absolute**: expires N seconds after it was *written*. Good for data that goes stale with time.
- **Sliding**: every access pushes the deadline out again (`GETEX key EX 1800` or `EXPIRE` on each read). Good for sessions: active users stay logged in, idle ones are dropped. Combine with an **absolute maximum** (e.g. 12 h) so a stolen session can't be kept alive forever.

## 4. The Synchronized-Expiry Problem

If 10,000 keys are written at deploy time with `EX 3600`, they all expire **at the same second** an hour later. Every one becomes a miss simultaneously, and the database takes the whole load at once. Same thing for one hot key: when it expires, every concurrent request misses together — a **cache stampede** (a.k.a. thundering herd, dog-piling).

Fixes:

1. **TTL jitter** — randomize: `ttl = base * (1 + random(-0.1, 0.1))`. Expirations spread out.
2. **Request coalescing (single-flight)** — only one caller recomputes a missing key; others wait for its result (in-process with a promise map, or across instances with a short Redis lock `SET lock:key 1 NX PX 5000`).
3. **Stale-while-revalidate** — keep serving the old value for a grace period while one background refresh runs.
4. **Probabilistic early expiration (XFetch)** — each reader, with a probability that grows as expiry approaches, refreshes early; the hottest keys almost never actually expire.

## 5. Soft TTL + Hard TTL (Stale-While-Revalidate)

Store the value with a *soft* expiry (when it should be refreshed) and a *hard* expiry (when it must not be served at all):

```ts
type Entry<T> = { value: T; softExpiresAt: number; hardExpiresAt: number };

// read path:
// now < soft            → fresh: serve
// soft ≤ now < hard     → stale: serve immediately AND trigger one background refresh
// now ≥ hard            → expired: must recompute before answering
```

HTTP has the same idea built in: `Cache-Control: max-age=60, stale-while-revalidate=300`.

## 6. TTLs in HTTP Caching

```
Cache-Control: public, max-age=300           # shared caches (CDN) may store it for 5 minutes
Cache-Control: private, max-age=60           # only the browser, not the CDN
Cache-Control: no-store                      # never store (sensitive data)
Cache-Control: public, max-age=31536000, immutable   # hashed static assets
CDN-specific:  s-maxage=600                  # shared-cache TTL, overrides max-age for CDNs
```

After `max-age`, a cache may **revalidate** with `If-None-Match: <etag>` and get a cheap `304 Not Modified` instead of the full body.

## Under the Hood: How Redis Actually Expires Keys

Redis stores expiry deadlines in a separate dictionary (key → absolute Unix ms timestamp). It deletes expired keys in two complementary ways:

1. **Lazy (passive) expiration** — every command that touches a key first checks its deadline; if passed, the key is deleted right there and the command behaves as if it never existed. Costs nothing for keys nobody reads… which is the problem: unread expired keys would sit in memory forever.
2. **Active expiration cycle** — about 10 times per second, Redis samples 20 random keys *that have a TTL*, deletes the expired ones, and **repeats immediately if more than 25% were expired**, within a CPU-time budget. Statistically this keeps the fraction of expired-but-not-yet-deleted keys below ~25%.

Consequences: memory isn't freed exactly at expiry; `DBSIZE`/`INFO keyspace` can count already-expired keys; and replicas don't expire keys on their own clock — the primary sends explicit `DEL`s to replicas, so a replica read may briefly return a logically-expired key (newer versions hide it at read time). Note also that `SET` without `KEEPTTL` **removes** an existing TTL — a common way keys accidentally become immortal.

![Thousands of keys written at the same moment with the same TTL all expire together and send a spike of misses to the database; adding jitter spreads expirations out and flattens the spike](diagrams/01-ttl-jitter.svg)

## Common Pitfalls

1. **No TTL at all** — one missed invalidation becomes permanent staleness, and memory fills with dead keys.
2. **Identical TTLs for bulk-written keys** — synchronized expiry → database spike. Add jitter.
3. **`SET` overwriting a key and dropping its TTL** (use `SET ... EX` again or `KEEPTTL`).
4. **Very long TTLs on data that must change** (permissions, prices during a sale).
5. **Sliding sessions without an absolute cap** — a hijacked session lives forever.
6. **Caching "not found" with a long TTL** — newly created items appear missing.

## Quick Reference

| Need | Redis | HTTP |
|---|---|---|
| expire after N s | `SET k v EX N` | `max-age=N` |
| keep existing TTL on update | `SET k v KEEPTTL` | — |
| sliding expiry | `GETEX k EX N` | — |
| inspect | `TTL k` / `PTTL k` | `Age` header |
| serve stale while refreshing | soft/hard TTL in app | `stale-while-revalidate=N` |
| avoid stampede | jitter + single-flight | `stale-while-revalidate` |
