The previous post covered what caching is and why Redis is fast. This one is the part that actually matters when you’re building something: where does the Redis client live, what does the route handler actually call, and what happens to your API the moment Redis itself has a bad day. Every snippet below is copy-pasteable and uses ioredis — the most widely used Redis client in the Node.js ecosystem — with Express for the API layer.
The building blocks, in order
Before the code, here’s the shape of what you’re actually building — click through each piece.
Connect
One Redis client, created once when the server boots, reused by every request. Not a connection pool — a single multiplexed connection is normal and correct for Redis.
1. Install and connect
1npm install ioredis
Create the client once, in its own module, and import it everywhere you need it — never create a new Redis instance per request.
1import Redis from 'ioredis';23export const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');45redis.on('error', (err) => {6 // Log it, but don't crash the process — the fail-open logic below7 // is what actually protects your routes from a Redis outage.8 console.error('Redis connection error:', err.message);9});
docker run -d --name redis -p 6379:6379 redis:7-alpine. No separate install, no config files — just a container you can throw away.2. The cache-aside helper
This one function is the entire pattern from the last post, in code: check Redis, and only do the expensive work if it isn’t there.
1import { redis } from './redis.js';23export async function getOrSetCache(key, ttlSeconds, fetchFn) {4 const cached = await redis.get(key);56 if (cached) {7 return JSON.parse(cached);8 }910 const fresh = await fetchFn();11 await redis.set(key, JSON.stringify(fresh), 'EX', ttlSeconds);12 return fresh;13}
JSON.stringify / JSON.parse. Redis stores strings — pass it an object directly and you’ll either get a runtime error or the literal text [object Object] cached back at you.3. Using it in a route
The route itself barely changes shape — the caching is entirely hidden inside the helper.
1import express from 'express';2import { getOrSetCache } from '../lib/cache.js';3import { db } from '../lib/db.js';45const router = express.Router();67router.get('/products/:id', async (req, res) => {8 try {9 const product = await getOrSetCache(10 `product:${req.params.id}`,11 300, // 5 minutes12 () => db.products.findUnique({ where: { id: req.params.id } })13 );1415 if (!product) return res.status(404).json({ error: 'Not found' });16 res.json(product);17 } catch (err) {18 console.error(err);19 res.status(500).json({ error: 'Something went wrong' });20 }21});2223export default router;
4. Invalidating on writes
A TTL alone means an edit can take up to 5 minutes to show up for other users. The fix is the same one from the last post: delete the key the moment the underlying data changes.
1router.put('/products/:id', async (req, res) => {2 const updated = await db.products.update({3 where: { id: req.params.id },4 data: req.body,5 });67 await redis.del(`product:${req.params.id}`);89 res.json(updated);10});
5. Not letting Redis take your API down
This is the part most tutorials skip. A cache is an optimization — if it’s unavailable, the correct behavior is to quietly fall back to the database, not to return a 500 to every user.
1export async function getOrSetCache(key, ttlSeconds, fetchFn) {2 let cached = null;34 try {5 cached = await redis.get(key);6 } catch (err) {7 console.error('Redis unavailable, skipping cache read:', err.message);8 }910 if (cached) return JSON.parse(cached);1112 const fresh = await fetchFn();1314 // Don't await the write — a slow or failing cache write should15 // never delay or break the actual response.16 redis17 .set(key, JSON.stringify(fresh), 'EX', ttlSeconds)18 .catch((err) => console.error('Redis write failed:', err.message));1920 return fresh;21}
6. A reusable caching middleware
Once you’re caching more than one or two routes, this pattern is worth lifting into middleware so you stop repeating it.
1export function cacheRoute(ttlSeconds) {2 return async (req, res, next) => {3 const key = `route:${req.originalUrl}`;45 try {6 const cached = await redis.get(key);7 if (cached) return res.json(JSON.parse(cached));8 } catch (err) {9 console.error('Cache read failed:', err.message);10 }1112 const originalJson = res.json.bind(res);13 res.json = (body) => {14 redis.set(key, JSON.stringify(body), 'EX', ttlSeconds).catch(() => {});15 return originalJson(body);16 };1718 next();19 };20}2122// usage — the route doesn't know it's being cached at all:23router.get('/products', cacheRoute(60), async (req, res) => {24 const products = await db.products.findMany();25 res.json(products);26});
7. Adding TTL jitter
From the cache-stampede warning in the last post — if a thousand keys all get set with the exact same TTL during a traffic spike, they all expire in the same instant later. A few seconds of randomness spreads that out.
1function withJitter(baseSeconds, maxJitterSeconds = 30) {2 return baseSeconds + Math.floor(Math.random() * maxJitterSeconds);3}45await redis.set(key, JSON.stringify(fresh), 'EX', withJitter(300));
Bonus: rate limiting with the same client
You already have a Redis connection open — INCR plus EXPIRE is enough to build a working rate limiter with no extra infrastructure.
1export async function isRateLimited(userId, limit = 100, windowSeconds = 60) {2 const key = `ratelimit:${userId}`;3 const count = await redis.incr(key);45 if (count === 1) {6 // Only the request that just created the key sets its expiry.7 await redis.expire(key, windowSeconds);8 }910 return count > limit;11}1213app.use(async (req, res, next) => {14 if (await isRateLimited(req.user.id)) {15 return res.status(429).json({ error: 'Too many requests' });16 }17 next();18});
ioredis
Client library used
60s – 5min
Typical cache TTL
Fall back to DB
On Redis failure
ioredis vs. the official node-redis client
ioredis
- Promises by default, no extra setup
- Built-in Cluster and Sentinel support
- Used heavily in production (BullMQ, Socket.IO adapter)
- What this post uses
node-redis (official)
- Maintained directly by Redis
- Promises by default since v4
- Slightly more explicit connect() step
- A fine choice too — pick one and be consistent
getOrSetCache helper on your slowest, most-read endpoint. Measure the before/after latency, confirm invalidation actually fires on writes, and only then reach for the middleware version once you’re repeating the pattern across routes.