Adding Redis to your application feels amazing for about a week. Suddenly your response times drop by 90%, the database stops sweating, and everything feels snappy. Your users notice. The team celebrates. You feel like a genius.
Then the weird bugs start. A customer swears they updated their profile, but they're still seeing their old email address. Another user sees a price that changed yesterday. And you start asking yourself the uncomfortable questions: How long should this data live in the cache? What happens if the database gets updated but Redis doesn't know about it? And the scariest one—what does my application do if Redis just… goes away?
I learned these questions the hard way a few years ago, working on an e-commerce backend that served product details to a mobile app. We'd slapped in Redis using the simplest approach we could think of—cache everything for an hour, invalidate nothing—and for a while it worked beautifully. Then one afternoon, our merchandising team updated the price of a popular winter coat. The change went into the database immediately. But because our cache had another 47 minutes before it expired, the app kept showing the old price to thousands of customers. We only found out because a customer service agent noticed a user complaining on Twitter. That afternoon I learned that a cache isn't just a performance tool—it's a consistency contract you're making with your users, whether you realize it or not.
The good news is that Redis gives you a clean, predictable set of commands. The patterns for using those commands effectively, though, are what separate a cache that quietly improves your application from one that creates sporadic, hair-pulling bugs. So let's walk through the patterns you'll actually use in production, the tradeoffs you're making with each one, and how to sleep better at night.
Cache-aside, sometimes called lazy loading, is the most common Redis caching pattern. And there's a good chance you're already using it, even if you never gave it a name. The idea is dead simple: the application checks the cache first. If the data is there, great—hand it back. If not, go fetch it from the database, store a copy in Redis, and then return it.
async function getUser(userId) {
// Check cache first
const cached = await redis.get(`user:${userId}`);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — fetch from the database
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
if (user) {
// Store it with a TTL so it doesn't live forever
await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
}
return user;
}
You know why this pattern is so popular? It's predictable. Nothing happens automatically. You don't populate the cache until someone actually asks for that specific piece of data. For a read-heavy workload where data changes infrequently—think user profiles, product descriptions, configuration settings—it's often all you need. You get the speed boost without much extra complexity.
The trap, though, is staleness. When that user updates their email address, your database writes the new value, but Redis still holds the old one. If your code doesn't explicitly invalidate user:123, the stale data sits there until the TTL naturally expires. For my e-commerce coat pricing fiasco, that was exactly the problem—we forgot to tell the cache that the data had changed. These bugs are intermittent by nature. They appear only when the cache hasn't been refreshed, which makes them maddening to track down. The customer who updates their profile once a day might never notice. The one who updates it twice within an hour? They'll call support.
Cache-aside is a solid default. But you need to be honest with yourself about how much inconsistency you can tolerate. If the answer is "almost none," keep reading.
Write-through addresses the staleness problem by updating the cache at the same moment as the database. Every time you write data, you write to both places, synchronously, before returning to the client.
async function updateUser(userId, data) {
// Update the database first
await db.query("UPDATE users SET name = $1, email = $2 WHERE id = $3", [
data.name,
data.email,
userId,
]);
// Then immediately refresh the cache with the latest data
const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
return user;
}
Now the cache always reflects reality. Readers never see stale information because writers update the cache as part of the write operation. That entire category of "Why is this data wrong?" bugs just disappears. If you're running a content management system or a product catalog where accuracy matters more than write speed, this pattern can feel like a warm blanket.
But there's a cost. Every write now touches two systems—the database and Redis—and that extra hop adds latency. For a write-heavy application, all those cache updates add up fast. You're also spending effort caching data that might never be read again. A user uploads three new profile photos in quick succession; you're paying to cache each one even if nobody views the profile between the second and third upload. It's wasteful in terms of write throughput.
Write-through makes sense when your read volume dwarfs your write volume, and when consistency matters. If a customer sees the wrong price for even a few seconds and that could cause a real problem, the slight write slowdown is worth it.
Write-behind flips the script entirely. Writes go straight to Redis first, and the application returns immediately. Redis acknowledges the write in microseconds because it's all in memory. Then, later, a background worker picks up the changes and pushes them to the database in batches. From the user's perspective, the operation is lightning fast.
async function recordPageView(pageId, userId) {
// Write to Redis immediately
const key = `pageviews:${pageId}`;
await redis.zincrby(key, 1, userId);
// That's it. The flush to the database happens asynchronously.
// A separate process reads from Redis and writes to the database in bulk.
}
This is the pattern you use when speed is everything and perfect accuracy is negotiable. Analytics events, page view counters, rate limit tracking, activity logs—data where losing a few records in a rare crash is acceptable. The tradeoff is clear: you get blazing writes, but you accept that if Redis crashes before the background flush runs, those writes vanish. You can reduce the risk with Redis persistence (RDB snapshots, AOF logs), but the risk never drops to zero. You absolutely would not use write-behind for financial transactions. Or medical records. Or anything where losing a write could have serious consequences.
The other thing to keep in mind is that the background flush process is your responsibility. You have to write the worker that reads from Redis and writes to the database. That adds some operational complexity. But for the right use case, the speed boost is hard to beat.
Read-through is similar to cache-aside, but the cache layer itself handles the database fetch. Your application code only ever talks to Redis. If Redis has the data, it returns it. If not, Redis (or a thin abstraction in front of it) goes to the database, stores the result, and hands it back. Your application doesn't see the difference.
Implementing pure read-through requires a Redis module like RedisGears, or you can build a lightweight abstraction in your application code. Here's what that abstraction often looks like:
// A simple read-through helper
async function getOrSet(key, ttl, fetchFn) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), "EX", ttl);
return data;
}
// Usage: your code stays clean
const user = await getOrSet(`user:${userId}`, 3600, () => {
return db.query("SELECT * FROM users WHERE id = $1", [userId]);
});
The big win here is that your business logic stays simple. You don't write conditional "is it cached?" checks everywhere. The caching behavior is consolidated in one place. This pattern also helps with a classic caching headache called the thundering herd—when a popular cache entry expires and dozens of requests all try to repopulate it at the same time, hammering your database. A well-built read-through layer can include a lock or a promise deduplication mechanism to handle that, which we'll get to in a bit.
Read-through isn't as common in the wild as cache-aside because it often adds a layer of abstraction that some teams find harder to debug. But when you're dealing with a high-traffic endpoint and you want to simplify your application code, it's a pattern worth knowing.
Every cache key needs an expiration. Without TTLs, your Redis instance will quietly fill up until it runs out of memory and starts evicting things at random, which is a great way to introduce unpredictable performance problems. But the strategy behind those TTL numbers deserves more thought than most people give it.
Fixed TTLs are the straightforward approach. Cache everything for 5 minutes. Or an hour. Pick a number that matches your data's freshness requirements and call it a day. The problem is that fixed TTLs ignore how often the data actually changes. A product description that hasn't been updated in three years expires at the same rate as a price that changes daily. That's fine if you want simplicity above all else. It's less fine if you're trying to balance freshness with cache hit rate.
Sliding TTLs reset the timer every time the data is accessed. Think of a user session. You might set a 30-minute sliding TTL. As long as the user keeps making requests, their session stays alive in Redis. Once they go idle, the clock runs out and the session naturally expires.
async function getUserSession(sessionId) {
const session = await redis.get(`session:${sessionId}`);
if (session) {
// Reset the clock on every access
await redis.expire(`session:${sessionId}`, 1800);
}
return session ? JSON.parse(session) : null;
}
Then there's probabilistic early expiration. You add a bit of random jitter to your TTLs so that a bunch of popular keys don't all expire at the same instant and trigger a stampede to the database. Instead of caching everything for exactly 3600 seconds, you scatter the expirations between, say, 3300 and 3900 seconds.
const BASE_TTL = 3600;
const jitter = Math.floor(Math.random() * 600); // 0-600 seconds
await redis.set(key, value, "EX", BASE_TTL + jitter);
That tiny bit of randomness spreads out the refresh load, and your database will thank you for it. I've seen a simple jitter drop database CPU spikes by over 30% during cache repopulation windows.
If you've been working on making your website pages faster, caching strategies like this are some of the highest-leverage optimizations you can make. A well-tuned cache can absorb the vast majority of your read traffic, and a poorly tuned one can make your application feel slower than if you had no cache at all.
There's a famous quote in computer science: "There are only two hard things in computer science: cache invalidation and naming things." Redis gives you solid tools for managing invalidation, but the thinking part is still on you.
The simplest method is key-based invalidation. When you update a user's record, you delete user:{id} from Redis. The next read request will be a cache miss, so it'll fetch fresh data from the database and repopulate the cache with the correct information.
async function updateUserProfile(userId, data) {
await db.query("UPDATE users SET ... WHERE id = $1", [userId]);
// Invalidate the specific cache entry
await redis.del(`user:${userId}`);
// Also invalidate any aggregated lists that included this user
await redis.del("users:active");
await redis.del("users:premium");
}
The tricky part is knowing which other keys are affected. That user's email might show up in a dashboard aggregation, a search index, and a few list endpoints. If you forget to invalidate any of those, you get scattered pockets of stale data. Keeping a mental map of all those dependencies gets unwieldy as your application grows.
Tag-based invalidation can help. You group related cache entries under a tag, then invalidate everything with that tag in one shot. Redis doesn't natively support tags, but you can build them with sets.
// Tagging a cache entry
async function cacheUser(userId, user) {
await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
await redis.sadd(`tag:user:${userId}`, `user:${userId}`);
}
// Invalidate by tag
async function invalidateUser(userId) {
const keys = await redis.smembers(`tag:user:${userId}`);
if (keys.length > 0) {
await redis.del(keys);
await redis.del(`tag:user:${userId}`);
}
}
It adds a little overhead, but it makes your invalidation logic far more maintainable. I've used this on a project where a single "product update" needed to clear the product page cache, the category listing cache, the search index cache, and the admin dashboard cache. Without tags, we would have had to hardcode every one of those key patterns. With tags, it was one call.
Picture this: you've got a popular cache key—something like a homepage feed or a trending items list. It's under heavy load, hundreds of requests per second. The TTL expires. Suddenly, every one of those in-flight requests sees a cache miss and rushes to the database to fetch the same data. Your database gets hammered with identical queries, and most of the work is wasted. That's the thundering herd.
The fix is a lightweight lock. The first request that spots the cache miss grabs a lock, does the work of fetching the data and populating the cache, and then releases the lock. All the other requests wait until the lock is released, then read from the newly warmed cache.
async function getPopularFeed() {
const cacheKey = "popular:items";
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Try to acquire a lock
const lockKey = `${cacheKey}:lock`;
const acquired = await redis.set(lockKey, "1", "NX", "EX", 5);
if (acquired) {
try {
const data = await db.query("SELECT * FROM items WHERE popular = true");
await redis.set(cacheKey, JSON.stringify(data), "EX", 300);
return data;
} finally {
await redis.del(lockKey);
}
} else {
// Another request is doing the work; wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
return getPopularFeed(); // retry
}
}
The NX flag on the SET command is the secret sauce. It means "only set if the key does not exist." That makes the lock atomic—only one request succeeds in acquiring it, and the others see null and know to wait. The lock itself has a short TTL (5 seconds here) so that if the fetching request crashes, the lock doesn't get stuck forever.
This does add a tiny bit of latency for the requests that have to wait. But it's the difference between a momentary pause and a database meltdown. For any high-traffic endpoint with an expensive backing query, this pattern is worth its weight in uptime.
Adding Redis to your stack means you now have another dependency that can fail. And on a long enough timeline, it will. The question isn't if, but how your application behaves when it can't reach Redis. The answer should be a deliberate design choice, not something you discover during a 3 a.m. outage.
The most common approach is graceful degradation. Your code tries Redis, and if that fails—because the connection timed out or the server is down—it falls back to the database and carries on. Response times will be slower, but the application stays functional.
async function getUser(userId) {
try {
const cached = await redis.get(`user:${userId}`);
if (cached) return JSON.parse(cached);
} catch (err) {
logger.warn("Redis unavailable, falling back to database", err);
}
return db.query("SELECT * FROM users WHERE id = $1", [userId]);
}
The catch here is your database. If Redis was absorbing 90% of your read traffic, all of that suddenly hits the database directly. You need to be sure your database connection pool and query performance can handle the load. For many applications, that spike is fine. For others, it's a cascading failure.
A circuit breaker can add another layer of resilience. If Redis fails a few times in a row, the circuit breaker "opens" and stops even trying to reach Redis for a cooling-off period (say, 30 seconds). That prevents every single request from suffering a connection timeout delay, which can add up and make your whole application feel sluggish. Once the cool-down period passes, the circuit breaker lets a few test requests through to see if Redis has recovered.
This approach pairs nicely with good monitoring and alerts, so you're aware of the problem while your application keeps limping along gracefully.
This isn't as dramatic as a thundering herd, but it's practical. I once worked on an API that cached rendered HTML fragments for a dozen different regional storefronts. The data was highly compressible—lots of repetitive markup and JSON blobs. We were burning through Redis memory faster than we'd budgeted, and the network transfer time for these chunky cache entries was adding 50-80 milliseconds to some responses. Not a disaster, but noticeable.
We added gzip compression before storing values in Redis. The overhead of compressing and decompressing was around 1-2 milliseconds, but the network savings more than made up for it. Our cache memory usage dropped by nearly 80%, and the total response time for cache hits actually went down because we were pushing fewer bytes over the wire.
const zlib = require("zlib");
async function cacheLargeData(key, data) {
const compressed = zlib.deflateSync(JSON.stringify(data));
await redis.set(key, compressed, "EX", 3600);
}
async function getLargeData(key) {
const compressed = await redis.getBuffer(key);
if (!compressed) return null;
return JSON.parse(zlib.inflateSync(compressed).toString());
}
If you're caching big blobs of text, it's a cheap win. JSON compresses beautifully—80-90% ratios are common for real API responses.
None of these patterns is universally correct. Cache-aside is the default for a reason: it's simple, it's predictable, and it works for the majority of read-heavy workloads. Write-through trades some write speed for strong consistency. Write-behind gives you screaming writes at the cost of potential data loss. Read-through can simplify your application code but asks for a bit more infrastructure discipline.
What matters is that you're deliberate. You should be able to answer three questions for every piece of cached data in your system: What happens when the cache is empty? What happens when the cache is wrong? And what happens when the cache isn't reachable at all? If you can answer those clearly, you're in good shape.
If you've been following our series on application performance—from why modern websites can feel slow to backend framework comparisons like Django vs FastAPI—caching is one of the highest-return investments you can make. A few hours of intentional cache design often does more for perceived performance than weeks of query optimization.
And if you take one thing away from all of this, let it be the lesson of the winter coat. Always, always think about what happens when the data in your cache no longer matches the truth. Your customers will notice before you do.
Need help designing a caching strategy that holds up in production? Red Surge Technology helps teams implement Redis patterns that actually work. Get in touch to talk about your performance goals.