Adding a nice cache to speed things up is a classic move, as we all know. And at first, it seems to work: instant responses, fewer database calls, less network traffic… everyone’s happy. Then traffic picks up, responses start slowing down, the product data doesn’t add up and a nervous customer reports information that “doesn’t look right”. Worse still, in a state of sheer panic, we find ourselves having to restart an application that has already collapsed. And suddenly, the cache that promised so many benefits becomes the main reason we’re awake at three in the morning, staring at logs and cursing the day we added it.

In this first instalment of Learning from mistakes, we look at five common mistakes in application caching. We’ll describe each bad practice, its symptoms and how to fix it.

1. The cache that never expires

We always start with the best intentions. The usual mindset is, “Oh, it’s fine as it is. I’ll invalidate the cache manually when I need to.” The result: a user keeps seeing a price that no longer exists for weeks, or a profile with data that’s months out of date. By then, we’re busy with other projects, and only get our lovely surprise when our friendly boss comes asking for an explanation, slamming an email from a furious customer onto the desk.

The solution is simple: whenever application data can change and keeping it fresh matters, we need to set a TTL for it in the cache. We can choose its duration according to how much stale data the specific business context can tolerate.

2. Leaving cache rebuilds to chance

This one is sneakier because nobody in particular is to blame. A cache key for a popular product or item expires, and hundreds of concurrent requests arrive during a traffic spike. Finding nothing in the cache, all of them try to recompute the same value, perhaps by running an expensive query or calling an API that, unfortunately, was never designed to handle hundreds of requests in parallel. This is known as a cache stampede.

How do we prevent it? Let’s coordinate the cache rebuild! Within a single process, we can use the singleflight pattern; across multiple instances, we can use a distributed lock. The idea is that one request updates the cache while the others wait, up to a time limit, for the value to become available. Alongside this, we use a slightly randomised TTL (jitter) to spread expiry across different keys.

One rebuild, several requests

Request 1 → Cache miss; Cache miss → Acquires Redis lock; Acquires Redis lock → Queries database; Queries database → Updates cache; Updates cache → Releases lock; Subsequent requests → Cache miss; Cache miss → Lock held?; Lock held? → Waits or reads stale data (Yes)
One request rebuilds the value. The others wait within a time limit or receive stale data when permitted. Jitter complements this coordination by spreading expiry across different keys.

3. Choosing the cache key while checking your fantasy football team

Choosing the right cache key seems like a minor detail. Then someone knocks at the door and voilà: “Hi! My name is… inconsistency!” A key that’s too generic, and suddenly an Italian user sees the interface in English or, worse, sees other users’ data. A key that’s too complex, and we’ll watch our cache grow endlessly, with cache hits becoming rarer than a blue moon.

How do we fix it? There’s nothing much to invent here; we just need to be rigorous: the key must contain all and only the data that uniquely identifies the cached item. If the key contains too little information or, at the other extreme, too much, it’s better not to implement a cache at all. Also, centralise key generation in one place in the application, rather than letting each module build keys its own way.

4. Trusting the cache too much

Brilliant idea! “I’ll just update the database. The cache will catch up on its own.” Er, no… that’s not how it works, but it’s amazing how often we fall into this trap. Meanwhile, half our users see the new value and the other half see the old one, depending on whether their request found anything in the cache. And if we’re talking about prices or stock availability, we’ll also get the delightful experience of someone shouting at us.

Don’t panic. Let’s fix it. There are two approaches. The first is write-through: write the new value to both the database and the cache. The second, simpler approach is to invalidate the key after the database commit: instead of updating the cache, delete the entry, and the next read will use the database to repopulate it.

Here too, be careful! Implement invalidation in one place, rather than reinventing it in ten different files, written by ten different developers with ten different ideas.

5. Caching errors with a TTL that’s too long

An external service returns a 500 or times out. To “optimise” things, our code caches the error using the same TTL as successful responses — let’s say 30 minutes. The external service recovers, perhaps after thirty seconds, but the application keeps returning the error. A thirty-second failure becomes a 30-minute disruption for our users.

Temporarily caching an error can help avoid bombarding a service that’s already struggling: this is negative caching. But the bad practice is indiscriminately caching errors with the same policy and TTL as successful responses.

How do we fix it? Define a dedicated policy: which errors to cache and for how long, distinguishing, for example, a missing resource from a transient failure. For transient failures, choose a short TTL based on how much recovery delay we can tolerate.

Quick summary

#MistakeMain symptomSolution
1Infinite TTLPermanently stale dataTTL matched to acceptable staleness
2Cache rebuilds left to chanceLoad spikes at the data sourceSingleflight or a distributed lock; TTL jitter
3Improvised cache keyData mixed up across users, products and other contextsInclude all and only the relevant variables; centralise key generation
4Unmanaged invalidationInconsistent data after a writeA policy applied after commit, with error and race condition handling
5Same TTL for data and errorsErrors returned even after recoverySelective negative caching with a dedicated TTL

Before we wrap up

Expiry, rebuilds, keys, invalidation and cached errors: five things worth designing properly before our friendly bosses remind us about them.

In Part 2, we’ll cover five more bad practices in application caching.

See you in the next instalment of Learning from mistakes.