Full-stack software engineer building useful tools. Constantly learning, with a habit of connecting dots across different domains.
A routine deployment finished. About a day later, our worker queue began climbing and reached millions of jobs within a few hours. Automated emails stopped firing, and our webhooks died.
The culprit? A single, silent null value resting quietly in Redis.
This is a write-up of an outage we had, what caused it, and how we fixed it.
We use a MaxMind database to map IP addresses to locations. One of our endpoints gets a lot of traffic (a couple of thousand requests per minute) and needs fast lookups, so we don’t want to hit a remote service or read from disk on every request.
To handle this, we use two layers:
We did it this way for two reasons:
There was also a fallback: if the cache is ever missing, the endpoint queues a background job to re-download the database from MaxMind and repopulate Redis. The idea was that if Redis got cleared or reset, the system would fix itself without anyone having to step in.
About a day after a routine deployment, the geolocation service stopped working.
Because the endpoint gets so much traffic, every incoming request found nothing usable in the cache and queued a background job to re-download the database. Instead of one job running, thousands were queued almost immediately. Within a few hours, the queue had grown to millions of jobs.
The worker pool couldn’t keep up, and the resulting resource starvation took down other critical systems, including automated emails and webhooks.
Our self-healing fallback wasn’t healing anything. Every request was independently trying to do the same recovery work at the same time, resulting in a massive cache stampede (or thundering herd).
I worked through it step by step, checking each part of the chain — the reader, Redis, the background job, the download — on its own instead of assuming any link was fine. Our code hadn’t changed, but the chain also runs through Redis, the MaxMind download, and a third-party library. A boundary you don’t own is exactly where a “this can’t happen” can happen.
That pointed to one thing: the key probably wasn’t empty, it held an invalid value, like a null or an empty payload. To the recovery logic, a key holding null looks the same as a missing key, so it keeps re-triggering the download forever.
Our code wasn’t supposed to allow that, but it was the only explanation that matched what I was seeing.
I asked the devops team to look at the actual value in Redis. A few people were skeptical because, in theory, the code couldn’t write a null. But the only way the system could be behaving like this was if the value was bad, so it was worth checking.
To stop the outage, we simply flushed that one Redis key. The system stabilized right away, the queue cleared, and traffic went back to normal. That also confirmed the null value had been there the whole time.
Flushing the key fixed the immediate outage, but it didn’t explain how a null got written in the first place. Reads from Redis happen on every request, but writes are supposed to be rare and controlled — the twice-a-week cron job owns refreshing the key.
So how did our application write a bad value? I went back to the PR and looked more carefully. The application logic hadn’t changed, but we had bumped our caching library, FusionCache, from v2.4.0 to v2.5.0.
Reading the changelog and the issue tracker, the sequence of events clicked into place.
The silent expiration. Our code relies on the twice-a-week cron job to refresh the cache with no expiration. In v2.4.0 that held — writes carried our “never expire” setting. In v2.5.0 the library changed how per-request options were applied: it ignored our configuration and silently fell back to its default ~1-day expiration. So the next write after the upgrade — the cron refresh that ran post-deploy — stamped a 1-day TTL onto the Redis key instead of “never.” A day later, that key expired for the first time.
The GetOrSet trap. One day after the deployment, the Redis entry expired. The next incoming request saw a cache miss and triggered a GetOrSet. On a hit, that GetOrSet just reads the database bytes back from Redis; on a miss, as we’ll see, our factory deliberately returns null instead of downloading.
The override that got ignored. On a Redis miss, our factory does something deliberate — it returns null and never downloads, because repopulation is the cron/background job’s responsibility, not the request path’s. To make that safe, we set Duration, DistributedCacheDuration, and JitterMaxDuration all to TimeSpan.Zero, meaning “if all you have is this null, don’t cache it anywhere.”
await _hybridCache.GetOrSetAsync<byte[]>(
AppConstants.MaxMindDatabaseCacheKey,
async () =>
{
// The factory intentionally returns null. The database bytes
// come from the distributed cache; we never want a request
// to download the database if it isn't already present.
return null;
},
options =>
{
options.Duration = TimeSpan.Zero;
options.DistributedCacheDuration = TimeSpan.Zero;
options.JitterMaxDuration = TimeSpan.Zero;
});
In v2.4.0 that worked: the null was discarded and the next request retried.
The poison pill. Because v2.5.0 ignored our per-request options, it ignored that TimeSpan.Zero override. It fell back to its default behavior and happily cached the null value directly into Redis.
Boom. The trap was set.
Now Redis held a cached null. Every single one of our requests hit Redis, retrieved the null, recognized it wasn’t valid MaxMind data, and queued a background job to fix it. We had accidentally engineered a perpetual cache stampede.
I made a couple of changes to ensure this never happens again:
null looks like a miss but never resolves — guard against it directly.