Module 4: Cache — The Read-Heavy Path

7. TTL and cache invalidation

Description

There's an old joke in the industry: "there are only two hard things in computer science: cache invalidation and naming things." It's a joke, but the first half is painfully true. Everything we've done in the module so far —the pattern, the eviction, the hit ratio, the working set— assumes something we haven't questioned until now: that the copy stored in the cache is still correct. This lesson faces the case where it isn't. When the original data changes in the database, the copy in the cache becomes stale, and from that instant the cache serves a lie: old data, with all the speed in the world, but wrong.

There are two tools to deal with this, and this lesson covers them. The first is the TTL (time to live): each cache entry expires on its own after a while, so a stale copy doesn't live forever —at most it lies during the TTL window—. It's a cheap and automatic safety net. The second is explicit invalidation: when the database changes, you delete (or update) the corresponding entry in the cache immediately, so the next read is a miss that brings the fresh data. You'll see both, when to use each, why in Enlace the problem is benign (links hardly change), and where it gets truly hard. And you'll see the boundary: when you have to invalidate many caches at once asynchronously, that's already a message queue and it lives in the event-driven architecture guide.

Connection to the module: lessons 3 to 6 made the cache fast and well-sized; this one keeps it correct. It closes the write path that lesson 3 left open (shorten writes to the database; what if a link changes?). And it prepares the project: sizing a cache includes deciding its TTL and invalidation policy, not just its memory and its hit ratio. It's the last piece before the capstone.

The blackboard with the day's menu

Think of it this way. A restaurant has the real menu in the kitchen —the source of truth, where the chef knows what there is— and a blackboard at the entrance where the waiter copies the menu so customers can see it without going into the kitchen. The blackboard is fast (the customer reads it in passing) but has a danger: it's a copy. If the kitchen runs out of salmon and nobody erases salmon from the blackboard, the blackboard keeps offering salmon that's no longer there. The customer orders it, and here comes the problem. The blackboard doesn't lie out of malice: it lies because it got out of date with respect to the kitchen.

How does the restaurant avoid serving an old menu? It has two tactics. The first is to erase the blackboard every so often —say, every morning it's wiped and the fresh menu is recopied—. That way, even if someone forgets to update it during the day, by the next morning it's up to date again: it's the automatic safety net, the TTL. The second, better but more disciplined, is that at the exact moment the salmon runs out in the kitchen, someone goes and erases it from the blackboard. That way the blackboard never lies for more than a few seconds: it's explicit invalidation, deleting the copy right when the original changes.

The blackboard is your cache; the kitchen is your database; the "salmon that ran out" is a piece of data that changed. The TTL is wiping the blackboard every so often (cheap, automatic, but it can serve old data until the next wipe). The invalidation is erasing the salmon as soon as it runs out (precise, but it requires someone to remember to do it on every change). Good restaurants use both: they invalidate instantly when they can, and they have the TTL as a net in case someone slips up. And here's Enlace's luck: its "menu" —the short_code → long_url mapping— almost never changes, so its blackboard almost never lies. The salmon, in Enlace, practically never runs out.

The cache is a copy, and a copy can become stale when the original changes. The TTL expires it on its own after a while (cheap safety net); the invalidation deletes it right when the database changes (precise but requires discipline). How much pain they give you depends on one thing: how often your data changes.

TTL: automatic expiration

The TTL is a lifetime you set on each entry when you store it: "this copy is valid for N seconds; after that, delete it on its own". In Redis it's done in the same write operation, with SETEX or with SET ... EX:

# ttl.py — populate the cache with a TTL (automatic expiration)
def resolve_with_ttl(short_code, cache, db, ttl_seconds=3600):
    long_url = cache.get(short_code)
    if long_url is not None:
        return long_url                          # HIT

    long_url = db.get_link(short_code)           # MISS -> DB
    if long_url is None:
        return None
    cache.set(short_code, long_url, ex=ttl_seconds)   # <-- populate WITH expiration
    return long_url

The only change from the cache-aside of lesson 3 is the ex=ttl_seconds in the set: the entry self-destructs in an hour (3600 s). What does this buy? Three things:

  1. It bounds how long the cache can lie. If a link changed and you forgot to invalidate it, the stale copy would live at most an hour, not forever. The TTL puts a ceiling on the damage of any old data.
  2. It limits negative caching. Remember lesson 3: caching the "doesn't exist" results (so a bot doesn't hit the database with fake codes) is useful, but dangerous without expiration —what if the code starts to exist?—. With a short TTL (a few seconds) on the negatives, you catch the bot and leave room for a new code to appear soon.
  3. It recycles cold-data space. An entry no one requests again expires and frees its RAM without waiting for LRU eviction to push it out. TTL and eviction complement each other: LRU throws out the cold when space is short; the TTL throws out the old even when there's space to spare.

The price of the TTL is a tradeoff you have to choose consciously: a short TTL means fresher data (less lying window) but less hit ratio (the entries expire soon and have to be repopulated with a miss); a long TTL means more hit ratio but more risk of serving something old. For Enlace, where links hardly change, you can afford a long TTL (hours or days) with no fear of staleness —the old data almost never exists—, gaining hit ratio. For data that changes often (a price, a balance), the TTL has to be short, and that's where the hit ratio suffers.

Careful: don't let everything expire at once

There's a trap with the TTL that connects with the mass miss of lesson 5. If you populate many entries at the same time with the same TTL —for example, you warm up the cache with ten thousand links at once, all with a one-hour TTL—, they all expire in the same instant an hour later. At that moment, ten thousand hits become ten thousand misses at once, and they fall together on the database: a cache stampede. The standard mitigation is jitter: instead of a fixed TTL of 3600 s, use a randomized TTL (for example, between 3300 and 3900 s), so that the expirations are spread over time instead of piling up. A bit of randomness in the TTL avoids the synchronized avalanche.

Invalidation: deleting when the database changes

The TTL is a passive net: it waits for time to pass. Invalidation is active: the moment the database changes, you go and fix the cache immediately. The most common and most robust way is surprisingly simple: delete the entry (don't update it). It's called delete-on-write or cache invalidation:

# invalidate.py — on writing to the DB, delete the cache entry
def update_link(short_code, new_url, cache, db):
    db.update_link(short_code, new_url)     # 1. write the source of truth
    cache.delete(short_code)                 # 2. delete the stale copy
    # the next read will be a MISS -> repopulates with the fresh data


def delete_link(short_code, cache, db):
    db.delete_link(short_code)               # 1. delete from the source of truth
    cache.delete(short_code)                 # 2. delete the copy

Notice the order and the choice to delete instead of update. The pattern is: first write the database (the source of truth), then delete the cache entry. The next read of that short_code will be a miss, will go to the database, bring the fresh data, and repopulate the cache —the cache-aside mechanism you already know, now in the service of correctness—. Why delete and not update the cache with the new value? Because deleting is simpler and safer:

  • Deleting is idempotent and without subtle race conditions. If two writes happen almost at once and both update the cache, they could leave it with the old write's value (if the sets arrive out of order). If both delete, the cache is left empty and the next read brings the correct value from the database, whoever wins the race.
  • You don't cache what maybe no one will request. If you update the cache on every write, you spend RAM on a piece of data that maybe won't be read soon —the same argument from lesson 3 against write-through—. Deleting lets lazy loading decide: it only repopulates if someone requests it.

That's why the practical rule of invalidation is "write the database, delete the cache" —in that order—, not "write the database, update the cache". Deleting is the default option in cache-aside.

The order matters: why the database first

A detail that costs dearly if done backwards: always the database first, the cache after. If you deleted the cache before writing the database, a dangerous window opens: between deleting the cache and writing the database, another read could arrive, find the cache empty (miss), read the old value from the database (which you haven't changed yet), and repopulate the cache with the old value —leaving you with a stale entry you won't invalidate anymore, because your invalidation already happened—. By writing the database first, any miss in that window reads the new value. The order "database → cache" closes that race. (There are even finer races that require more advanced techniques, but for this module's level, "database first, delete the cache after" is the correct and sufficient rule.)

Worked example: the cache that lies, and how to silence it

Let's see the staleness happen for real, and then the invalidation fixing it. In scenario A we change a link's destination in the database but don't invalidate the cache; in scenario B we do the same but with delete-on-write (database first, then delete). This is a hypothetical case —Enlace in practice hardly edits destinations, but we force it to see the mechanism—:

# stale_demo.py — the cache serves old data, and the invalidation corrects it
def resolve(code, cache, db):
    url = cache.get(code)
    if url is not None:
        return url, "HIT"
    url = db.get(code)
    if url is not None:
        cache.set(code, url)
    return url, "MISS"

# A) without invalidation: the DB changes, the cache stays with the old one
cache, db = Cache(), DB()                 # db starts with .../promo (the old one)
resolve("aX9kR2q", cache, db)             # 1st read: miss, populates the cache
db.update("aX9kR2q", "https://new.example.com/landing")   # changes ONLY the DB
print("A 2nd read:", resolve("aX9kR2q", cache, db))       # <- what does it return?

# B) with invalidation: DB first, then delete the copy
cache, db = Cache(), DB()
resolve("aX9kR2q", cache, db)             # 1st read: miss, populates
db.update("aX9kR2q", "https://new.example.com/landing")   # 1. write the DB
cache.delete("aX9kR2q")                   # 2. delete the stale copy
print("B 2nd read:", resolve("aX9kR2q", cache, db))       # <- and now?

What to expect. With python stale_demo.py:

A 2nd read: ('https://old.example.com/promo', 'HIT')      <- STALE
B 2nd read: ('https://new.example.com/landing', 'MISS')   <- FRESH

There's the problem and its cure, side by side. In scenario A, the second read is a hit —fast— but returns .../promo, the old URL, even though the database already has .../landing. The cache didn't find out about the change and serves the lie with all the speed in the world: that's a stale entry. In scenario B, we delete the copy right after writing the database, so the second read is a miss that goes to the database, brings the fresh .../landing, and along the way repopulates the cache with the correct value for the next ones. The price of the correctness is that single extra miss after each change —cheap— and the benefit is not lying again. Notice the detail that connects with the order rule: it worked because we wrote the database before deleting the cache; if we did it the other way around, a read slipped in the middle would repopulate with the old and we'd go back to scenario A's problem.

Why Enlace has it easy (and where it gets complicated)

Here's Enlace's luck, and it's worth understanding because it explains why we chose this case for the module. Enlace's data —short_code → long_url— is almost immutable: when someone shortens a URL, that mapping stays fixed practically forever. A short link isn't "reassigned" to another destination; at most it's deleted (if it expires or the user removes it) or never changes. That means invalidation in Enlace is a rare and simple case: it almost only happens on a delete_link (when a link expires), and there it's enough to delete the cache entry. There are no constant updates chasing the cache. That's why Enlace can use long TTLs (good hit ratio) and minimal invalidation (only on deletion): the blackboard almost never lies because the menu almost never changes.

Where does the problem get complicated? In systems whose data changes often, which are most of the hard cases of the real world:

  • A bank balance changes with each transaction: the cache becomes stale in seconds, so you need immediate invalidation and a short TTL, and even so you live on the edge of serving an old balance.
  • A product's price changes with promotions: if the cache serves the old price, you charge wrong.
  • A user's profile that they edit themselves: they expect to see their change reflected now, not in an hour.

In all of those, the high change frequency turns invalidation into the central problem of the design —hence the joke—. Enlace, by having almost immutable data, dodges the hardest part. It's a deliberate pedagogical choice: you learn the tools (TTL, delete-on-write) in a case where they don't burn you, to carry them later to cases where they do.

The boundary: invalidating many caches at once

There's one more step of difficulty this module does not cover, and it's good to know where it lives. Up to here we assumed one cache. But a large system has many app servers, each perhaps with its own local cache, or several Redis replicas in different regions. When a piece of data changes, how do you ensure that all those caches delete their copy, and not just the one you touched? Deleting them one by one from the write code doesn't scale or isn't reliable (what if one is down at that moment?).

The solution is asynchronous: when the database changes, you publish an event —"the link aX9kR2q changed"— in a message queue (or an event bus), and each cache subscribes to those events and deletes its copy when it receives it. That way the invalidation propagates to all the caches in a decoupled and resilient way, without the write code having to know each cache. But that's already event-driven architecture —queues, publish/subscribe, guaranteed delivery, event order— and it's a whole topic in its own right. In this guide we mention it as a boundary and leave it there:

Invalidating many distributed caches asynchronously, publishing change events in a message queue, is a topic of the event-driven-architecture-guide. Here we work with one cache (or a shared Redis) and direct invalidation: write the database, delete the cache. Event-based propagation is the next step, in the sibling guide.

Common mistakes

Updating the cache instead of deleting it on write. What happens: someone, when changing a piece of data, writes the new value both to the database and to the cache (cache.set with the new value). Under concurrent writes, two updates can arrive at the cache out of order and leave it with the old value, while the database has the new one —a staleness that won't correct itself anymore—. Why it happens: "update on both sides" sounds more complete than "delete on one". How to detect it: if you have concurrent writes to the same data and sometimes the cache disagrees with the database, suspect the set in the invalidation. How to fix it: in cache-aside, delete the entry (don't update it) on write. The next read repopulates it from the source of truth, with the correct value, whoever wins the race. Delete-on-write is simpler and safer than update-on-write.

Invalidating the cache before writing the database. What happens: someone deletes the cache entry first and then writes the database. In the window between the two operations, a concurrent read finds the cache empty, reads the old value from the database (which hasn't been changed yet), and repopulates the cache with it —leaving a permanent stale entry—. Why it happens: the order seems not to matter, but it matters a lot. How to detect it: if after an update the cache sometimes stays with the old value despite you invalidating, check the order. How to fix it: always the database first, the cache after. That way, any miss in the window reads the already-updated value. The order "write the source of truth, then delete the copy" closes the race.

Putting the same TTL on everything and causing an avalanche. What happens: someone warms up or populates many entries with an identical fixed TTL (all 3600 s), and an hour later they all expire at the same time, turning thousands of hits into thousands of simultaneous misses that saturate the database —a cache stampede—. Why it happens: a fixed TTL is the easiest to write, and the synchronized effect isn't seen until it happens. How to detect it: if you see periodic spikes of misses and database load separated by exactly the TTL value, it's the synchronized expiration. How to fix it: add jitter —randomize the TTL in a range (for example, 3600 s ± 10%)— to spread the expirations over time. A bit of noise in the TTL keeps everything from expiring at once. (And warm up the cache in a staggered way, not all at once.)

Exercises

Exercise 1 — Choose the TTL. For each piece of data, say whether a short TTL (seconds), a long one (hours/days), or a very long / no TTL is best, and why in one sentence, thinking about how often it changes. (a) Enlace's short_code → long_url mapping. (b) A bank account balance. (c) The result of "does this short_code not exist?" (negative caching). (d) A company's logo (an image that almost never changes).

See solution
  • (a) short_code → long_url: long TTL (hours/days) or very long. The mapping is almost immutable, so serving the copy for a long time is safe and maximizes the hit ratio. Enlace affords this luxury.
  • (b) Bank balance: short TTL (seconds) — or better, don't cache. It changes with each transaction; a copy older than a few seconds may already be wrong and authorize incorrectly. Many systems don't even cache the balance, or use a TTL of seconds with immediate invalidation.
  • (c) "Doesn't exist" (negative caching): short TTL (seconds). You want to catch the bot trying fake codes, but a code could start to exist soon, so the "doesn't exist" lie shouldn't live long. Seconds is the sweet spot.
  • (d) Company logo: very long TTL (or no expiration, with a version in the key). It almost never changes, so a TTL of days or weeks is ideal. Common trick: when it does change, the key is also changed (logo_v2), which invalidates without depending on the TTL.

The mechanical rule: the correct TTL is inversely proportional to how often the data changes. Immutable data (Enlace, logos) → long TTL and good hit ratio; volatile data (balance) → short TTL and hit ratio sacrificed for freshness. The change frequency decides.

Exercise 2 — The invalidation order. A colleague wrote this function to change a link's destination. Under load, sometimes the cache stays with the old URL forever. What's the bug and how is it fixed?

def update_link(short_code, new_url, cache, db):
    cache.delete(short_code)          # delete the copy
    db.update_link(short_code, new_url)   # then write the DB
See solution

The bug is the order: it deletes the cache before writing the database. Consider this sequence under load:

  1. cache.delete(short_code) — the cache is empty for that code.
  2. (right here) another request does resolve(short_code): miss (the cache is empty) → reads the database, which still has the old URL (the update hasn't arrived yet) → repopulates the cache with the old URL.
  3. db.update_link(...) — now the database has the new URL, but the cache just re-cached the old one, and there's no pending invalidation to correct it.

Result: the cache serves the old URL indefinitely, even though the database has the new one. The fix is to reverse the order —database first, cache after—:

def update_link(short_code, new_url, cache, db):
    db.update_link(short_code, new_url)   # 1. source of truth first
    cache.delete(short_code)              # 2. delete the copy after

Now, any read that falls in the window between the two lines reads the new value from the database (already written), so repopulating with it is correct. The rule to take away: in invalidation, you always write the source of truth before touching the copy.

Exercise 3 — Why does Enlace dodge the hard part? In a couple of sentences, explain why cache invalidation is "hard" in general (the famous joke) but easy in Enlace, and what property of Enlace's data makes the difference. Then say what would change if Enlace allowed editing the destination of an already-created link.

See solution

Invalidation is hard in general because, when a piece of data changes often, you have to ensure that every cached copy is deleted or updated at exactly the right moment, coordinating concurrent writes, orders, and (in large systems) many distributed caches —and any slip leaves a stale copy serving lies—. In Enlace it's easy because its data, the short_code → long_url mapping, is almost immutable: once created, it doesn't change. With no changes, there's no staleness; the cached copy stays correct indefinitely, and the only real invalidation happens on deleting a link (expiration), which is a simple and infrequent case. The key property is the immutability of the data.

If Enlace allowed editing the destination of an already-created link, the problem would become like everyone else's: each edit would have to invalidate the cached entry (delete-on-write, database first), the TTL would have to be shorter to bound the lying window, and —if there are several caches— the invalidation would have to be propagated (the asynchronous step with queues, the events guide's boundary). In other words, the "edit link" feature would turn Enlace from an easy cache case into a hard one. That's why many real shorteners do not allow editing the destination: immutability isn't just a pedagogical simplification, it's a design decision that keeps the cache cheap and correct.

Summary and next step

In this lesson you faced the hard side of the cache: the copy can become stale when the original changes —the blackboard that keeps offering sold-out salmon—. You saw the two tools. The TTL expires each entry on its own after a while: a cheap safety net that bounds how long the cache can lie, with the freshness-vs-hit-ratio tradeoff (short TTL = fresh but fewer hits; long = more hits but more risk) and the trap of synchronized expiration, fixed with jitter. The invalidation deletes the entry right when the database changes, with two golden rules: delete instead of update (delete-on-write, safe against races) and database first, cache after (closes the window of repopulating with old data).

You understood why Enlace has it easy —its data is almost immutable, so you almost never have to invalidate and it can use long TTLs— and where it gets complicated —balances, prices, profiles that change often—. And you placed the boundary: invalidating many distributed caches asynchronously, with events in a message queue, is a topic of the event-driven architecture guide, not this one.

Before moving on you should be able to: explain what a stale entry is and why it happens; describe TTL and invalidation and when to use each; recite the two rules of invalidation (delete, don't update; database first); and say what property of Enlace's data makes its invalidation easy.

What comes next is putting it all together. In lesson 8, the project, you'll size Enlace's complete cache: set a target hit ratio, compute the working-set memory, obtain the resulting latency with the formula, and choose the eviction and TTL policy —using all the pieces of the seven lessons to produce a design defensible with numbers.

Resources

  • Redis — Expiration (EXPIRE, SETEX, TTL) — the official documentation of how TTLs are set and managed in Redis, the concrete tool behind this lesson's ex=ttl_seconds. It also explains how Redis deletes expired keys (lazy + sampling).
  • Cache invalidation — Wikipedia — the overview of invalidation strategies (delete, update, TTL) and why the problem is notoriously hard. Context for the famous joke and for understanding why Enlace's immutability is so fortunate.
  • Cache stampede — Wikipedia — the reference on the avalanche of misses caused by synchronized expiration, and the mitigations (jitter in the TTL, locking, early recomputation). It expands the "same TTL for everything" trap of this lesson.
  • Designing Data-Intensive Applications, Kleppmann — on consistency and derived data — the framework of why keeping a copy (the cache) consistent with its source is an underlying problem of systems design, not a detail. It prepares the ground for the eventual consistency you'll see in module 7 of this guide.