Fixing Redis Caching Bugs For Real
Cover photo by Taylor Vick on Unsplash
Your cache is lying to you, and it’s probably because of your invalidation strategy. I spent an entire weekend chasing a bug where our dashboard showed stale inventory counts even after a successful database update. The logs looked perfect, but the UI kept showing yesterday’s numbers.
Where did the data go?
I checked the Redis set command. It was there. I checked the read logic. It was pulling from the key. The problem was race conditions during concurrent requests. My code was writing to the database, then updating the cache, but a simultaneous read request was finishing just before the write finished, caching the old value again. Classic cache stampede, right? Except it wasn’t a stampede. It was just bad timing.
// The problematic patternasync function updateItem(id, data) { await db.items.update(id, data); await redis.set(`item:${id}`, JSON.stringify(data)); // This is where I lost control}The fix isn’t more locks
“Why don’t you just use a distributed lock?” you ask. That’s a fair question, but adding distributed locks for every read or write operation usually turns your high-speed cache into a bottleneck that’s slower than your SQL database. Instead, I moved to a cache-aside pattern with a short TTL (time to live) and switched to cache invalidation via deletion. Stop trying to keep the cache perfect. Just make it wrong for a very short duration.
// Better approach: delete, don't updateasync function updateItem(id, data) { await db.items.update(id, data); await redis.del(`item:${id}`); // Force the next request to fetch fresh data}(I realize this approach isn’t perfect for every single scenario, like heavy write loads where you might prefer write-through caching, but it covers 90 percent of the apps I build.)
Check your types
Another thing I see often is people serializing complex objects into Redis and forgetting that JSON isn’t natively typed. When I finally fixed the race condition, I started getting parsing errors because I wasn’t handling the case where a key simply doesn’t exist anymore. Always, and I mean always, wrap your cache reads in a try-catch block or a safe-parsing function. If the cache fails or returns garbage, your app should fall back to the database gracefully.
async function getCachedItem(id) { try { const cached = await redis.get(`item:${id}`); return cached ? JSON.parse(cached) : null; } catch (e) { return null; // Just let the system query the DB }}It’s tempting to treat Redis as a secondary database, but that’s how you get technical debt. I’ve found it’s safer to treat it as an ephemeral layer that can vanish at any moment without taking down the entire system. Don’t worry about the cache being slightly out of sync. Worry about the app crashing when it is empty. How do you handle cache misses in your production environment?