Some bugs throw errors. The worst ones don't.
There's no exception, no alert, no red graph and no failing test. Every service is healthy. An organiser changes their ticket types, and people keep seeing the old ones for up to ten minutes.
Nothing is wrong. Everything is wrong. Welcome to cache invalidation.
Decision 1: cache the hottest read
Every event page on tunyt needs that event's ticket types. That makes it one of our hottest reads, read far more often than it changes, which is the textbook case for a cache. So we put one in front of it. The pattern is called cache-aside, and every backend has some version of it:
async function getTicketTypes(eventId: string) {
const key = `cache:ticket:type:event:${eventId}`;
const hit = await cacheGet(key);
if (hit) return hit;
const rows = await db.ticketType.findMany({ where: { eventId }, take: 200 });
await cacheSet(key, rows, { ttlSeconds: 600 });
return rows;
}
Read from the cache. On a miss, read from the database and write the result back.
Decision 2: ten minutes
The TTL is a trade. Longer means less load on the database and more risk of serving something stale, and shorter means fresher data and more load. Ticket types don't change often, so ten minutes saves a lot of database work.
The TTL is also our upper bound on how long any staleness bug can live, which matters more than it sounds. Hold that thought. Those ten lines are fine, and they're never where the bug is.
The bug is the writer you add later
The first way this breaks is a person, and it shows up months later.
Somebody adds a new way to change ticket types. A bulk edit, an admin tool, a one-off script. It updates the database correctly and passes its tests. It never touches the cache, because the person writing it had no reason to know the cache existed.
So the database says one thing and the cache says another, and the cache wins, for up to ten minutes. If what changed was a price or a sold-out flag, that's a support ticket at best. This class of bug is on our own performance audit list, which tells you how normal it is.
The race nobody notices
The second way it breaks is subtler, and it lives inside the ten lines. Two requests, almost the same moment:
- A reader misses the cache and reads the ticket types from the database.
- A writer updates the ticket types and deletes the cache key.
- The reader, holding data from before the update, writes it back into the cache.
The cache now holds stale data, and nothing will remove it until the TTL runs out. Every step was correct. The order was the bug.
You can engineer around this with versioned keys or a short delayed second delete. But it's the honest reason the TTL exists. The TTL isn't there to make data fresh. It's there to put a ceiling on every bug you haven't found yet.
Decision 3: don't shorten the TTL
The tempting fix for both bugs is a shorter TTL. Make it a minute and the damage is smaller.
We didn't. The cache exists to take load off the database, and every time you shorten the TTL you hand some of that load back and still serve stale data, just for less time. You'd be paying permanently to shrink a bug instead of removing it. The TTL is the safety net, and it shouldn't be the plan.
Decision 4: every writer invalidates, through one helper
There's one helper that deletes the ticket-type cache for an event, and every code path that changes ticket types calls it. It's a written rule, and it's on the checklist anyone, person or coding agent, runs through before shipping a cache: invalidation wired at every mutation site.
A helper beats deleting the key inline because a helper is searchable. You can find every caller in seconds, and a code path that changes ticket types without calling it stands out in review.
Decision 5: one shape for every key
Every key looks like cache:{type}:{identifier}. That sounds cosmetic, and it isn't. Search the codebase for a type and you find every place that reads or writes it. If you can't find all the readers, you can't be sure you've invalidated them.
How I review caches now
A cache is a second copy of the truth. Every second copy needs someone keeping it honest, and in code that someone is every function that writes the first copy.
So when a new cache shows up in a review, I don't start with the read. The read is ten lines and it's always fine. I ask to see every write.
Top comments (0)