Most APIs read the same rows far more often than they change them, and fetching a product from the database on every request repeats work whose result has not changed. Redis is an in-memory key-value store that answers in well under a millisecond and is shared by every instance of your app. After this lesson you will be able to connect Express to Redis, cache service results with the cache-aside pattern, invalidate entries on writes, cache whole responses with a middleware, and combine that with HTTP caching headers.
npm install redis// src/lib/redis.js
import { createClient } from "redis";
export const redis = createClient({ url: process.env.REDIS_URL ?? "redis://localhost:6379" });
redis.on("error", (err) => console.error("Redis error", err));
await redis.connect();The official redis client is promise-based. A local server starts with docker run -p 6379:6379 redis:7-alpine; hosted options such as Upstash or Redis Cloud provide a REDIS_URL. Create one client and import it wherever needed.
Cache-aside (also called lazy loading) is the most common pattern: look in the cache, fall back to the database on a miss, store the result with a time-to-live.
// src/services/product.service.js
import { redis } from "../lib/redis.js";
import { Product } from "../models/product.model.js";
const TTL = 300; // seconds
export async function getProduct(id) {
const key = `product:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const product = await Product.findById(id).lean();
if (product) await redis.set(key, JSON.stringify(product), { EX: TTL });
return product;
}Every entry gets a TTL (EX in seconds) so stale data expires even if invalidation is missed. Use a consistent key scheme such as entity:id or entity:list:owner:page, because you will need to find and delete related keys later.
A cache is only useful if writes clear what they change. Delete the affected keys after a successful update:
export async function updateProduct(id, data) {
const product = await Product.findByIdAndUpdate(id, data, { new: true, runValidators: true }).lean();
await redis.del(`product:${id}`);
await clearPattern("products:list:*"); // any cached listing may include it
return product;
}
async function clearPattern(pattern) {
for await (const keys of redis.scanIterator({ MATCH: pattern, COUNT: 100 })) {
if (keys.length) await redis.del(keys);
}
}SCAN walks the keyspace incrementally; never use KEYS pattern in production, because it blocks the server while it scans everything. If list caches become hard to track, prefix them with a version number stored in Redis and bump the version on writes, which makes old keys unreachable until their TTL removes them.
For public, read-only endpoints such as a product catalogue, cache the serialised response itself with a small middleware:
// src/middleware/cache.js
export const cacheResponse = (ttl) => async (req, res, next) => {
if (req.method !== "GET") return next();
const key = `resp:${req.originalUrl}`;
const hit = await redis.get(key);
if (hit) return res.type("json").send(hit);
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode === 200) redis.set(key, JSON.stringify(body), { EX: ttl }).catch(() => {});
return originalJson(body);
};
next();
};
router.get("/products", cacheResponse(60), productController.list);Include the user ID in the key for private data, or restrict this middleware to endpoints that return the same body for everyone; serving one user's cached response to another is the classic caching bug.
Redis caching still costs a round trip. HTTP caching headers let browsers and CDNs skip the request entirely:
| Header | Effect |
| --- | --- |
| Cache-Control: public, max-age=300 | Browsers and CDNs reuse the response for 5 minutes |
| Cache-Control: private, no-store | Never cache (auth responses, personal data) |
| ETag (Express sets a weak one by default) | Client sends If-None-Match; Express replies 304 with no body when unchanged |
Set the header with res.set("Cache-Control", "public, max-age=300") before res.json() in the handler or a small middleware.
INFO stats in Redis) before adding more caches; a cache with a 10 percent hit rate adds complexity for nothing.In cache-aside, what happens on a cache miss?
redis client and reuse the connection.set(key, value, { EX }).del and SCAN-based pattern clearing or versioned key prefixes; never KEYS in production.Cache-Control and ETag headers so clients and CDNs avoid the request entirely.Next lesson: Real-Time Communication with Socket.IO — push updates from the server to connected browsers the moment data changes.