A practical guide to caching with Redis
How to use Redis caching without shooting yourself in the foot: real patterns for read-through caches, invalidation, TTLs, and the stampede problem.

Caching is the part of backend work where the wins are obvious and the bugs are sneaky. A cache turns a 200ms Postgres query into a 2ms memory lookup, right up until it serves a stale price to a customer or melts your database the moment it expires. This is a practical walkthrough of using Redis for caching in a Node and TypeScript app, with the patterns that actually hold up in production and the traps that catch most teams.
Why Redis, and when you actually need it
Redis is an in-memory key-value store. It is fast because it keeps data in RAM and speaks a tiny, predictable protocol. For caching, that is mostly all you need: set a key, read a key, give it an expiry.
Before you reach for it, be honest about whether you need it. If your Postgres query is slow because it is missing an index, fix the index. A cache layered on top of a bad query just hides the problem and adds a second source of truth you now have to keep in sync. Redis earns its place when the data is genuinely expensive to compute or fetch, gets read far more than it gets written, and can tolerate being slightly out of date.
Good candidates: rendered product listings, a user's permission set, results of an aggregation that scans millions of rows, responses from a slow third-party API. Bad candidates: anything that must be transactionally correct on every read, like an account balance at the moment of a payment.
The read-through pattern
The most common and most useful pattern is read-through (also called cache-aside). You check the cache first. On a miss, you load from the database, write the result back to the cache, and return it.
Here is a typed helper using the ioredis client. The generic getOrSet is the workhorse you will reuse everywhere.
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
async function getOrSet<T>(
key: string,
ttlSeconds: number,
loader: () => Promise<T>,
): Promise<T> {
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached) as T;
}
const fresh = await loader();
// NX so a concurrent writer does not clobber a fresher value.
await redis.set(key, JSON.stringify(fresh), "EX", ttlSeconds, "NX");
return fresh;
}
// Usage: cache a product for 5 minutes.
async function getProduct(id: string) {
return getOrSet(`product:${id}`, 300, () =>
db.product.findUniqueOrThrow({ where: { id } }),
);
}A few things in that small block are doing real work. We store JSON, so reads and writes are symmetric. We always set a TTL, because a cache without expiry is a memory leak with extra steps. And we check for null explicitly rather than a truthy check, because an empty string or the number zero is a perfectly valid cached value.
Key naming matters more than it looks
Use a consistent, colon-separated scheme: product:123, user:42:permissions, cart:total:42. The prefix tells you what the key is, makes debugging with redis-cli --scan --pattern 'product:*' sane, and gives you a clean unit for invalidation. Decide on the convention early and write it down, because renaming keys across a running system is no fun.
Invalidation, the genuinely hard part
The famous joke about there being two hard problems in computing (cache invalidation and naming things) is funny because it is true. The moment your underlying data changes, the cache is wrong, and you have to decide how wrong you can stand to be.
Two strategies cover most cases.
TTL-based expiry is the simplest: pick a TTL you can tolerate and let the cache go stale within that window. If product descriptions can be five minutes out of date, a 300 second TTL is fine and you write zero invalidation code. This is the default you should reach for first.
Explicit invalidation is for data that must update promptly after a write. Delete the key in the same code path that mutates the source of truth.
async function updateProduct(id: string, data: Partial<Product>) {
const updated = await db.product.update({ where: { id }, data });
await redis.del(`product:${id}`);
// Also drop any derived keys this write affects.
await redis.del(`product:${id}:related`);
return updated;
}The trap here is derived data. A single product update might invalidate the product key, a category listing, a search result, and a homepage block. If you cannot enumerate every dependent key, you have two honest options: tag related keys (store a set of keys per entity and delete the whole set), or lean on shorter TTLs so staleness self-heals. Do not pretend you tracked every dependency when you did not.
The cache stampede
Here is the bug that takes down databases. A popular key expires. In the next few milliseconds, a thousand concurrent requests all miss the cache, all run the expensive loader at once, and all hammer Postgres simultaneously. The cache was protecting your database, and the instant it blinked, the database fell over. This is a stampede (or thundering herd), and it is exactly the failure mode that bites hardest when traffic spikes.
The fix is a short-lived lock so only one request rebuilds the value while the rest wait briefly and read the fresh result.
async function getOrSetLocked<T>(
key: string,
ttlSeconds: number,
loader: () => Promise<T>,
): Promise<T> {
const cached = await redis.get(key);
if (cached !== null) return JSON.parse(cached) as T;
const lockKey = `lock:${key}`;
const gotLock = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!gotLock) {
// Someone else is rebuilding. Wait a beat, then read their result.
await new Promise((r) => setTimeout(r, 50));
const retry = await redis.get(key);
if (retry !== null) return JSON.parse(retry) as T;
return loader(); // Fallback: rebuild rather than hang.
}
try {
const fresh = await loader();
await redis.set(key, JSON.stringify(fresh), "EX", ttlSeconds);
return fresh;
} finally {
await redis.del(lockKey);
}
}For very hot keys you can go further and refresh proactively: store a logical expiry inside the value and rebuild it in the background slightly before the real TTL hits, so readers never see a miss. That is more machinery, so save it for the handful of keys that warrant it.
Caching the absence of data
A subtle source of load is the negative lookup. If getProduct("does-not-exist") always misses the cache and always hits the database, an attacker (or a buggy client) can bypass your cache entirely by requesting nonsense IDs. Cache the miss too, with a short TTL and a sentinel value, so repeated lookups for missing data stay cheap.
Operational details people skip
Set maxmemory and an eviction policy on the Redis instance itself. For a pure cache, allkeys-lru is the sensible choice: when memory fills, Redis drops the least recently used keys instead of returning errors. Without it, a cache that grows unbounded will eventually refuse writes.
Treat Redis as unreliable in your code. It is a separate process that can be down, slow, or restarting. Wrap cache reads so a Redis failure degrades to a direct database call rather than a 500. The cache is an optimization, not a dependency you bet the request on.
async function safeGet(key: string): Promise<string | null> {
try {
return await redis.get(key);
} catch {
return null; // Treat any cache error as a miss.
}
}Finally, watch your hit rate. Redis exposes keyspace_hits and keyspace_misses via the INFO stats command. A hit rate below fifty percent usually means your TTLs are too short, your keys are too granular, or you are caching things nobody reads twice.
Takeaway
Start with the read-through pattern and a TTL you can live with. Add explicit invalidation only where staleness actually hurts, guard your hottest keys against stampedes, cache misses as well as hits, and always let a Redis outage fall back to the database. Caching with Redis is not complicated once you treat it as what it is: a fast, forgetful copy of the truth, never the truth itself.
If you want a second pair of eyes on a caching layer before it ships, our performance engineering work is exactly the kind of thing we do.

