A response cache skips the handler, the one thing an ETag cannot do. The middleware below replaces res.json: on a miss the handler runs and its serialized body is stored under the request URL; on a hit the answer comes from the map and next is never reached.
function cache(ttlMs) {
const store = new Map();
return (req, res, next) => {
const hit = store.get(req.originalUrl);
if (hit && hit.expires > Date.now()) {
return res.set('X-Cache', 'HIT').type('json').send(hit.body);
}
res.json = (payload) => { // replaced for this response only
const body = JSON.stringify(payload);
store.set(req.originalUrl, { body, expires: Date.now() + ttlMs });
return res.set('X-Cache', 'MISS').type('json').send(body);
};
next();
};
}
app.get('/top-cached', cache(30_000), (req, res) => res.json({ data: topBooks() }));$ node cachebench.mjs # autocannon -c 50 -d 5, i9-7980XE, Node 25.8.0 uncached 1,228 req/s 40.14 ms avg p99 89 ms cached 10,903 req/s 4.10 ms avg p99 9 ms
Nine times the throughput and a tenth of the latency, for a route whose data is thirty seconds stale. That is why caching beats micro-optimization: the fastest handler is the one that does not run.
An in-process Map is per worker, so eight cluster workers warm eight copies, and it dies with the process. Redis 2,763 fixes both, and the same client backs your sessions (Session Stores for Production) and rate limiter (Rate Limiting). Only the storage lines change, plus async/await:
const redis = new Redis(process.env.REDIS_URL); // npm i ioredis
const key = `res:${req.user?.id ?? 'anon'}:${req.originalUrl}`;
const hit = await redis.get(key); // HIT branch as above
redis.setex(key, ttlSec, body).catch((e) => req.log?.warn(e)); // never block on itA write drops what it invalidated with await redis.del(key). No Redis server is installed on the machine used for this book, so that listing is shown as code and was not executed here; every measured number above is from the in-process version. Caching and Redis covers the client and the Redis data structures.
Two rules prevent the classic bugs. Put every varying input in the key — user id, query string, Accept-Language — or you will serve one reader's data to another; and treat a cache error as a miss, so a catch that logs and continues keeps the API alive while Redis restarts.