What Is Negative Caching and Why Do We Need It?
The Same Question, Again and Again
Imagine you have an API that returns a user by their ID.
To avoid reading from the database on every request, you add a cache. A cache keeps a temporary copy of an answer so you can return it faster next time.
The flow looks like this:
Request for user 42
↓
Check the cache
↓
Nothing cached? Read from the database
↓
User found? Save it in the cache and return it
The next request for user 42 gets the cached user. No database call needed.
Now someone requests user 999, who doesn’t exist.
The cache has nothing. The database has nothing. Your API returns “User not found,” and you don’t save anything in the cache.
What happens when the same request comes in again?
The exact same database lookup.
If that request comes in 1,000 times, you make 1,000 database calls just to give the same answer:
“This user doesn’t exist.”
What Is Negative Caching?
Negative caching means temporarily remembering that something was not found.
When the database says user 999 doesn’t exist, you save that result in the cache too.
First request → Cache empty → Database → Not found → Cache “not found”
Next request → Cache says “not found” → Return “User not found”
Now the next request can skip the database.
The word “negative” just refers to the answer: we looked for something, and it wasn’t there.
The Cache Needs Three Answers
There is one detail that makes this work.
Your code must tell the difference between “we haven’t checked” and “we checked, and it doesn’t exist.”
| Cache result | Meaning | What happens next? |
|---|---|---|
| User data | We found this user earlier | Return the user |
| A “not found” marker | We checked earlier, and the user wasn’t there | Return “User not found” |
| No entry | We don’t have an answer yet | Read from the database |
If you treat the last two rows as the same thing, you’ll keep calling the database.
A Simple Code Example
One way to store the answer is with a small object:
// A user was found
{ found: true, value: user }
// A user was not found
{ found: false }
Here’s simplified JavaScript showing the lookup. Assume cache.get() returns undefined when there is no entry, and cache.set() accepts a lifetime in seconds.
async function getUser(id) {
const key = `user:${id}`;
const cached = await cache.get(key);
if (cached !== undefined) {
return cached.found ? cached.value : null;
}
const user = await database.findUser(id);
if (user === null) {
await cache.set(key, { found: false }, 30);
return null;
}
await cache.set(key, { found: true, value: user }, 300);
return user;
}
Here, a missing user is remembered for 30 seconds. An existing user is cached for 300 seconds, or five minutes. These are example values; the right times depend on your application.
The important part is { found: false }. It’s an actual cache entry, so the next request knows it can return null without checking the database.
Also, “not found” should mean the lookup succeeded and returned no user. A database timeout doesn’t tell us whether the user exists, so we shouldn’t cache it as a missing user.
How Much Work Does It Save?
Suppose X, Y, and Z are missing users, and these requests arrive one after another:
X, Y, Z, X, Y, Z, X, Y
Assuming none of the cache entries expire or get removed during these requests:
| Database calls | |
|---|---|
| Without negative caching | 8 |
| With negative caching | 3 |
Only the first request for each missing user reaches the database. The remaining five use the cached “not found” answer.
Repeated requests for missing data that keep reaching the database are often called cache penetration.
Negative caching helps when those missing keys are requested again. If every request asks for a different missing ID, each new ID still needs a lookup.
What If the User Gets Created Later?
This is the main thing to watch out for.
Imagine this sequence:
- Someone requests user
999. - You cache “not found” for 30 seconds.
- User
999gets created five seconds later. - Another request still gets the cached “not found” answer.
The user exists, but the cache has an old answer.
Two things help here.
Keep “Not Found” Entries Short
The cache lifetime is called TTL, short for “time to live.” Once it ends, the entry expires and the next request checks the database again.
Missing results usually need a shorter TTL because something that doesn’t exist now could exist soon.
Update the Cache When You Write
After creating the user in the database, replace the cached “not found” entry with the new user. Or delete the entry so the next request checks again.
If your app runs on multiple servers, that update needs to reach the cache each server reads. A shared cache, such as Redis, makes this easier than keeping a separate cache inside each process.
There is still a timing issue to consider: an older lookup could finish after the write and put “not found” back into the cache. A short TTL limits how long that old answer stays around. Systems that need immediate accuracy also need to coordinate reads and writes.
A Few Things to Remember
- Use a clear marker for “not found.” It must be different from both real data and an empty cache slot.
- Give missing results a short lifetime, and update or remove them when the data changes.
- Limit how much space these entries can use. Endless random missing IDs can fill the cache too.
- Negative caching helps repeat requests after the result is saved. Several requests arriving together can still all reach the database before that happens.
The next time you add a cache, check what happens when the database returns nothing. That answer may be worth remembering too.