When I first started thinking seriously about API performance, my instinct was simple:
If the database is slow, optimize the database.
Add indexes. Improve the query. Tune the connection pool.
And sometimes, that's exactly what you should do.
But eventually, you run into a different problem.
The database isn't necessarily slow.
You're just asking it the same question too many times.
Imagine an endpoint that returns product information.
GET /api/products/123
The product changes once every few hours, but the endpoint gets thousands of requests every minute.
Without caching, every request goes straight to PostgreSQL:
Request
↓
API
↓
PostgreSQL
↓
Response
The database is doing the same work over and over again.
That's where Redis comes in.
The Simple Idea Behind Caching
Instead of asking the database every time, we store frequently requested data temporarily.
Request
↓
Redis
↓
HIT ─────→ Response
│
MISS
↓
Database
↓
Redis
↓
Response
The first request pays the database cost.
The next requests don't.
In a Node.js API, a simple cache-aside implementation looks like this:
const key = `product:${productId}`;
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
const product = await prisma.product.findUnique({
where: { id: productId },
});
if (!product) {
throw new Error("Product not found");
}
await redis.set(
key,
JSON.stringify(product),
{ EX: 300 }
);
return product;
That's the basic pattern:
Check Redis → cache miss → query database → store result → return.
Simple.
But this is where caching stops being just a performance optimization.
Because now we have two copies of the data.
And they can disagree.
The Stale Data Problem
Suppose Redis contains:
product:123
price: ₦45,000
Then someone updates the product:
Database
price: ₦50,000
Redis still contains:
₦45,000
If the cache has a five-minute TTL, users could receive the old price for another five minutes.
This is the real challenge with caching.
Making data fast to access is easy. Keeping it correct is harder.
That's why TTL alone isn't enough.
TTL vs Invalidation
TTL answers:
"How long should this cache entry exist?"
Invalidation answers:
"When should I remove it because the underlying data changed?"
For example:
await prisma.product.update({
where: { id: productId },
data: updateData,
});
await redis.del(`product:${productId}`);
Now the next request becomes a cache miss and retrieves the latest value from the database.
For many APIs, cache-aside + TTL + delete-on-write is a very practical starting point.
You don't need a complicated caching architecture on day one.
Then There's the Cache Stampede
Here's where things get interesting.
Imagine a popular cache entry expires:
product:123
At the exact same time, 1,000 requests arrive.
They all check Redis:
MISS
MISS
MISS
MISS
...
And suddenly all 1,000 requests hit PostgreSQL.
The cache that was supposed to protect the database has just created a database traffic spike.
This is commonly called a cache stampede or thundering herd.
For extremely hot keys, you can prevent this by allowing only one request to rebuild the cache while the others wait.
Redis can help here through distributed locking.
But you wouldn't want to introduce locking everywhere.
If a query takes 10ms, the complexity may not be worth it.
If rebuilding the cache takes several seconds under heavy traffic, it's a different conversation.
Optimize for the workload you actually have.
What Should You Cache?
This is probably the most important question.
I wouldn't cache something simply because Redis is available.
Good candidates usually have three characteristics:
Frequently requested
+
Expensive to retrieve
+
Safe to be slightly stale
For example:
Product details
Public profiles
Search results
Configuration
Reference data
Expensive aggregations
I'd be much more careful with:
Account balances
Payment state
Authorization decisions
Inventory
Transaction state
Especially in financial systems.
If a wallet contains ₦100,000 and Redis says ₦100,000 while the database says ₦70,000 after a transaction, using that cached value for a financial decision is dangerous.
Fast wrong data is still wrong data.
For critical state, the database should remain authoritative.
Your Cache Key Is Part of Your Architecture
A cache key isn't just a random string.
This:
123
is a terrible cache key.
This is better:
user:123
And in a multi-tenant system:
tenant:456:user:123
For filtered results:
products:category:phones:page:1
Every parameter that changes the response needs to be represented appropriately in the key.
Otherwise, you can end up returning the wrong cached response to the right request.
That's a correctness bug—not a performance bug.
Redis Should Accelerate Your System, Not Become Your System
One of the most important design decisions is what happens when Redis fails.
For a cache-aside architecture, I'd rather have:
Redis available
↓
Use cache
Redis unavailable
↓
Use database
The API might become slower.
But it remains functional.
That's a much better failure mode than:
Redis unavailable
↓
Entire API unavailable
If your application completely depends on Redis just to retrieve data that already exists in PostgreSQL, you've accidentally turned your cache into a critical dependency.
Measure the Difference
Finally, don't add Redis and declare victory.
Measure it.
Before caching:
Average latency: 400ms
P95 latency: 800ms
DB queries/sec: 2,000
After caching:
Average latency: 70ms
P95 latency: 150ms
DB queries/sec: 500
Cache hit rate: 85%
Now you have evidence.
The goal isn't to make Redis busy.
The goal is to make the entire system do less unnecessary work.
The Bigger Lesson
Redis isn't what makes an API fast.
Avoiding unnecessary work makes an API fast.
Redis is simply one of the tools that helps you do that.
The moment you introduce a cache, you're making a trade:
More memory
+
More complexity
+
Potentially stale data
in exchange for:
Lower latency
+
Less database load
+
Higher throughput
+
Better scalability
The best caching strategy isn't the one that puts the most data into Redis.
It's the one that eliminates the most unnecessary work while keeping the system correct.
That's the difference between simply using Redis and actually designing with Redis.
Top comments (0)