Slow third-party APIs ruin user experience and run up massive bills. Last week, I was building a dashboard that pulled from a sluggish external feed. Every single page load took over a second, and I was quickly hitting my API rate limits. To fix it, I dropped a Redis cache in front of the requests. Response times plummeted from 2 seconds to under 5 milliseconds, and my API usage flatlined. Here is how to build a reusable Redis caching middleware for Express to do the exact same thing.

Setting up Redis and Node.js
First, we need a running Redis instance and a clean Node project. I always use Docker for local Redis setup—it saves me from messing up my local environment with native installs.
Run this to spin up a local Redis container:
docker run -d --name local-redis -p 6379:6379 redis:7-alpine
Next, let’s initialize the Node project and grab our dependencies. We only need Express and the official Redis client.
mkdir node-redis-cache
cd node-redis-cache
npm init -y
npm install express redis
Double-check that your Redis container is up and listening on port 6379. Run docker ps to make sure it’s actually running.
Simulating a slow API endpoint
Before writing any caching logic, we need a slow endpoint to test against. Let’s create server.js and build a basic Express server. I’ll simulate a heavy database query or a sluggish third-party API call using a helper function that forces a two-second delay.
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000; // Simulate a slow database query or external API call
const fetchSlowData = () => { return new Promise((resolve) => { setTimeout(() => { resolve({ status: 'success', data: [ { id: 1, name: 'Product A', price: 49.99 }, { id: 2, name: 'Product B', price: 79.99 }, { id: 3, name: 'Product C', price: 120.00 } ] }); }, 2000); });
}; app.get('/api/products', async (req, res) => { try { const data = await fetchSlowData(); res.json(data); } catch (error) { res.status(500).json({ error: 'Something went wrong' }); }
}); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
Fire this up with node server.js and hit http://localhost:3000/api/products in your browser or terminal. You’ll wait exactly two seconds every single time. If a hundred users hit this simultaneously, your server’s event loop is going to feel the pain.
Connecting to Redis cleanly
Let's set up our Redis connection. The modern redis npm package (v4+) uses promises out of the box, which keeps our async/await code clean. We also want to listen to connection events so we actually know if our cache is online or failing.
Create a file named redisClient.js:
const { createClient } = require('redis'); const client = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379'
}); client.on('error', (err) => console.error('Redis Client Error', err));
client.on('connect', () => console.log('Connected to Redis successfully')); (async () => { await client.connect();
})(); module.exports = client;
Keeping the Redis client in its own file makes it easy to import anywhere. For instance, you can reuse this exact client to build a Redis-backed idempotency middleware for Express later on.
Building the caching middleware
The cleanest way to handle caching in Express is with custom middleware. It should intercept the request, check Redis for a cached copy, and return it instantly if found.
If it’s a cache miss, we need to capture the response from our route handler, store it in Redis for the next user, and then send it on its way. We can do this by temporarily overriding Express’s default res.send method. Create cacheMiddleware.js:
const redisClient = require('./redisClient'); const cacheMiddleware = (duration) => { return async (req, res, next) => { // Use the request path as the unique cache key const key = `cache:${req.originalUrl || req.url}`; try { const cachedResponse = await redisClient.get(key); if (cachedResponse) { console.log(`Cache hit for key: ${key}`); return res.json(JSON.parse(cachedResponse)); } console.log(`Cache miss for key: ${key}. Fetching fresh data.`); // Override res.send to intercept the response before sending it res.originalSend = res.send; res.send = (body) => { // Only cache successful 2xx responses if (res.statusCode >= 200 && res.statusCode { console.error('Failed to save to Redis:', err); }); } res.originalSend(body); }; next(); } catch (error) { console.error('Cache middleware error:', error); // Fallback to the database/API if Redis fails next(); } };
}; module.exports = cacheMiddleware;
We’re using redisClient.setEx to set a Time To Live (TTL) on our cache keys. Never cache indefinitely—your Redis memory will eventually fill up and crash. If you end up needing more advanced Redis patterns later, you can also use it to handle external API rate limits with BullMQ.
Wiring it up to Express
Now let’s update server.js to use our new middleware. We’ll set a cache duration of 60 seconds on our slow endpoint.
const express = require('express');
const cacheMiddleware = require('./cacheMiddleware');
const app = express();
const PORT = process.env.PORT || 3000; const fetchSlowData = () => { return new Promise((resolve) => { setTimeout(() => { resolve({ status: 'success', data: [ { id: 1, name: 'Product A', price: 49.99 }, { id: 2, name: 'Product B', price: 79.99 }, { id: 3, name: 'Product C', price: 120.00 } ] }); }, 2000); });
}; // Cache this endpoint's response for 60 seconds
app.get('/api/products', cacheMiddleware(60), async (req, res) => { try { const data = await fetchSlowData(); res.json(data); } catch (error) { res.status(500).json({ error: 'Something went wrong' }); }
}); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
Restart your server with node server.js and let’s see how much latency we actually shave off.
Testing the latency drop
Let’s use curl to measure the exact response times. Open a terminal and fire off the first request:
curl -o /dev/null -s -w "Total time: %{time_total}sn" http://localhost:3000/api/products
You’ll see an output like this:
Total time: 2.015s
The server console will log: Cache miss for key: cache:/api/products. Fetching fresh data.
Now, run the exact same curl command again:
curl -o /dev/null -s -w "Total time: %{time_total}sn" http://localhost:3000/api/products
The response returns almost instantly:
Total time: 0.004s
The console logs: Cache hit for key: cache:/api/products. We just dropped the response time from 2000ms to 4ms. That’s a massive win for your server load and your users.
The Gotcha: What happens when Redis crashes?
Here’s a real-world scenario that broke my production app the first time I shipped a caching system: Redis went down for maintenance, and my entire API crashed with it.
If your cache goes offline, your users shouldn’t see a 500 error. The app must gracefully fall back to fetching the data directly from the slow database or API. Our middleware handles this inside the try/catch block:
try { const cachedResponse = await redisClient.get(key); // ...
} catch (error) { console.error('Cache middleware error:', error); next(); // Gracefully skip caching and proceed to the route handler
}
Let’s actually test this. Kill your Docker Redis container to simulate an outage:
docker stop local-redis
Now, hit the API endpoint again. The request will take 2 seconds to load, but it will complete successfully instead of throwing an unhandled exception and crashing your Node process. This fallback logic is what keeps your app resilient.
FAQ
Should I cache every endpoint?
Absolutely not. Only cache endpoints that serve read-heavy, slow-changing data. Don’t cache endpoints returning real-time, highly dynamic, or user-sensitive data like checkout carts or live chat feeds.
How do I cache user-specific data safely?
If you need to cache user-specific data, bake their unique user ID directly into the cache key—something like cache:user:123:/dashboard. If you don’t, you risk serving one user’s private data to another.
Is Redis better than Memcached?
For modern Node.js apps, yes. Memcached is a simple, pure key-value store. Redis gives you richer data structures (lists, sets, hashes) and built-in persistence. Redis is almost always the better choice here.
How do I clear the cache when data updates?
This is cache invalidation. When a write or update happens (like a POST, PUT, or DELETE request), you need to delete the corresponding cache key using redisClient.del(key). This forces the next GET request to fetch fresh data.
Wrapping up
Now that you have a working Redis cache, you can expand your setup to handle traffic spikes and security. Check out how to build a Redis sliding window rate limiter in Node.js to protect your API from abuse and scraping bots.
For official configuration options and advanced command usage, check out the official Redis documentation and the Express framework guide on middleware design. Understanding how HTTP headers interact with your cache is also useful, as outlined in the MDN Web Docs on HTTP caching.

