Introduction
Slow APIs frustrate users and overload databases. In many applications, the same data is requested again and again, such as product lists, user profiles, or course catalogs. In this article, we add Redis caching to a Node.js API and measure the difference.
The Problem
Consider an endpoint that returns the top 20 products:
app.get("/products/top", async (req, res) => {
const products = await db.query(
"SELECT * FROM products ORDER BY sales DESC LIMIT 20"
);
res.json(products.rows);
});Every request hits the database. Under heavy traffic, the query runs thousands of times per minute even though the data changes only occasionally. In our test, this endpoint averaged ~480 ms per request.
What Is Redis?
Redis is an in-memory key-value store. Because data lives in RAM, reads typically take under a millisecond. It is commonly used for caching, session storage, and rate limiting.
The Cache-Aside Pattern
We use the cache-aside (lazy loading) pattern:
- Check Redis for the data.
- Cache hit: return it immediately.
- Cache miss: query the database, store the result in Redis with an expiry, then return it.
Request -> Redis -> (hit) -> Response
|
(miss)
v
Database -> store in Redis -> ResponseStep 1: Set Up Redis
Run Redis with Docker:
docker run -d --name redis -p 6379:6379 redis:7-alpineInstall the client:
npm install redisStep 2: Connect to Redis
const { createClient } = require("redis");const redis = createClient({ url: "redis://localhost:6379" }); redis.on("error", (err) => console.error("Redis error:", err));
(async () => { await redis.connect(); })(); ```
Step 3: Add Caching to the Endpoint
app.get("/products/top", async (req, res) => {
const cacheKey = "products:top20";try { const cached = await redis.get(cacheKey); if (cached) { return res.json(JSON.parse(cached)); } } catch (err) { console.error("Cache read failed:", err); }
const result = await db.query( "SELECT * FROM products ORDER BY sales DESC LIMIT 20" );
try { await redis.set(cacheKey, JSON.stringify(result.rows), { EX: 60 }); } catch (err) { console.error("Cache write failed:", err); }
res.json(result.rows); }); ```
Key details:
EX: 60sets a 60-second TTL, so stale data expires automatically.- Cache errors are caught so a Redis outage never breaks the API. It simply falls back to the database.
Step 4: Invalidate the Cache on Updates
When data changes, delete the cached copy so users don't see outdated results:
app.put("/products/:id", async (req, res) => {
await db.query("UPDATE products SET name = $1 WHERE id = $2", [
req.body.name,
req.params.id,
]);await redis.del("products:top20"); res.sendStatus(204); }); ```
Results
We load-tested the endpoint with 1,000 requests:
| Metric | Without Cache | With Redis |
|---|---|---|
| Avg response time | ~480 ms | ~12 ms |
| DB queries | 1,000 | 17 |
| Requests/sec | ~210 | ~2,800 |
Response time dropped by roughly 97%, and database load fell almost entirely. Your numbers will vary with data size and hardware.
Best Practices
- Always set a TTL so memory doesn't fill with stale keys.
- Use clear key names, such as
products:top20oruser:42:profile. - Cache only read-heavy, slow-changing data.
- Prevent cache stampedes. When a popular key expires, many requests may hit the database at once. Use locking or slightly randomized TTLs.
- Monitor hit rate. A low hit rate means the cache isn't helping.
- Never cache sensitive per-user data under shared keys.
Common Pitfalls
- Forgetting invalidation, which leads to users seeing old data.
- Caching data that changes every second.
- Treating Redis as the source of truth. It is a cache, and the database remains authoritative.
- Letting a Redis failure crash the API instead of falling back gracefully.
Conclusion
Adding Redis with the cache-aside pattern takes only a few lines of code but delivers a large performance gain. Start with your slowest, most-read endpoints, set sensible TTLs, invalidate on writes, and measure the results. Caching is one of the highest-impact optimizations available to backend developers.