Module 3 · Lesson 3 of 7
Expiry and invalidation
TTLs, deleting on write, and why stale data is a product decision.
The organisers move the band's show from 8 pm to 9 pm. The database is updated in a millisecond. The event page, served from the cache, keeps saying 8 pm to everyone for the next hour, and a few hundred fans turn up early. A cache holds copies, and every copy can go out of date. This lesson is about deciding how out of date is acceptable, and how to bring copies back in line.
Every cached value is a guess
When you put event:the-band-mumbai in Redis, you are betting that the event won't change before someone reads it again. Most of the time you win. When you lose, a user sees stale data: a value that was true when it was cached and isn't any more.
You can't make staleness disappear without throwing away the point of caching. If you checked the database on every read to confirm the cached value was still current, you'd be doing a database query per request again. So the real question isn't "how do I avoid stale data?" It's "for this piece of data, how stale is acceptable, and what happens if I'm wrong?"
That's a product decision as much as an engineering one. Ask the person who owns the feature:
- A trending list that's 5 minutes old? Nobody notices.
- A poster image that's a day old after a redesign? Mildly embarrassing.
- A show time that's an hour old? Fans at the wrong door.
- A seat shown as free when it's sold? A failed checkout and an angry fan, unless the booking step re-checks the database (it must).
There are two tools for keeping staleness in its box: expiry and invalidation. You'll usually use both.
Tool one: expiry (TTL)
Every value you SET gets a time to live, after which the cache deletes it by itself. SET event:42 … EX 300 means "forget this in 5 minutes". The next read after that is a miss, which reloads fresh data.
A TTL is a ceiling on staleness. No matter what else goes wrong (a bug in your invalidation code, a write that bypassed the app, a message that got lost), no value can be wrong for longer than its TTL. That's why you should always set one, even when you also invalidate on write. It's the safety net under everything else.
Choosing the number is a trade:
- Short TTL (seconds): fresher data, more misses, more database load.
- Long TTL (hours): fewer misses, but changes take longer to appear, unless you also invalidate.
Tool two: invalidate on write
Waiting for a TTL is passive. When you change the data, you know exactly which key is now wrong, so you can act straight away. The standard move in cache-aside is:
- Write the change to the database.
- Delete the cached key.
The next read misses and reloads the new value. You delete rather than update the cache because deleting is simple and safe: you don't need to rebuild the value (which may join five tables) in the write path, and if two writes race, a deleted key can't end up holding the wrong one of them.
Order matters. Update the database first, then delete the key. If you delete first, a read can slip in between, miss, load the old value from the database (your write hasn't happened yet) and put it back in the cache, where it stays until the TTL runs out.
Check yourself
An organiser updates the show time. Which sequence is safest in cache-aside?
The race that still gets through
Even "update, then delete" has a rare hole. It needs a reader to miss and read the database just before your write, but then put the value in the cache just after your delete:
It's unlikely: the reader's database query has to be slower than the whole write-plus-delete. But at 20,000 reads a second, unlikely things happen daily. Practical defences, cheapest first:
- Keep a TTL. It bounds the damage, which is the main reason it's non-negotiable.
- Delete twice. Delete again a second or so after the write (a "delayed double delete"), catching any reader that was in flight.
- Version the value. Store an
updated_ator version number in the cached value and refuse toSETan older version over a newer one.
For a show time, a TTL plus one delete is plenty. For something where staleness costs money, don't serve it from the cache at all.
Who else writes to the database?
Delete-on-write only works if every write goes through code that knows to delete. Real systems leak:
- An admin edits a row directly in SQL during an incident.
- A nightly job updates prices in bulk without touching Redis.
- A second service shares the same table.
Each of these leaves stale keys behind until they expire. Options: make every writer go through one module that handles invalidation, or listen to the database's change stream (change data capture) and delete keys from there, so invalidation happens no matter who wrote. Again, the TTL is what saves you from the writers you forgot about.
Invalidating many keys at once
One change can make many keys stale. Renaming a venue affects every event page at that venue, every city listing that shows them, and every search result. Tracking all of those keys is fragile. Two tricks help:
- Namespace versions. Put a version in the key prefix:
venue:17:v4:…. To invalidate everything about venue 17, increment its version tov5. Old keys are never read again and quietly expire. - Shorter TTLs on aggregate views. Lists and search results are hard to invalidate precisely, so give them TTLs of a minute or two and accept that much staleness.
Your turn
For each Gatepass value, pick a TTL and say whether you would also delete on write: the event details page, the seats-left count, a user's 'My tickets' list, and the city home page list.
Stuck? Ask who notices if it's stale, how fast, and how much it costs them.
Key takeaways
- Every cached value can be stale. How stale is acceptable is a product decision, made per piece of data.
- Always set a TTL. It caps staleness even when invalidation code has bugs or someone writes behind your back.
- On writes, update the database first, then delete the key. Delete instead of updating the cache.
- A rare race can still re-cache an old value. A TTL bounds it; double deletes or versioned values close it further.
- Data whose staleness costs money or seats should be read from the database, not the cache.
Finished reading?
Mark it complete to tick it off in the course.