Caching with Redis

Advanced
13 min

Caching with Redis

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.

Connecting to Redis

bash
npm install redis
javascript
// 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 in the service layer

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.

javascript
// 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.

Invalidation on writes

A cache is only useful if writes clear what they change. Delete the affected keys after a successful update:

javascript
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.

Caching whole responses

For public, read-only endpoints such as a product catalogue, cache the serialised response itself with a small middleware:

javascript
// 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.

Let the client cache too

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.

Tips

  • Measure hit rate (INFO stats in Redis) before adding more caches; a cache with a 10 percent hit rate adds complexity for nothing.
  • Wrap cache reads in a fallback: if Redis is down, log and query the database rather than failing the request.
Quick Quiz
Question 1 of 3

In cache-aside, what happens on a cache miss?

Key Takeaways

  • Redis is a shared in-memory store; connect once with the redis client and reuse the connection.
  • Cache-aside: read cache, fall back to the database, write back with a TTL using set(key, value, { EX }).
  • Invalidate on writes with del and SCAN-based pattern clearing or versioned key prefixes; never KEYS in production.
  • Response-caching middleware suits public GET endpoints; include the user in the key for private data.
  • Combine Redis with 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.

Caching with Redis - Express.js | CodeYourCraft | CodeYourCraft