All Writing
Backend9 min read

How to use Redis in a Node.js API.

The cache-aside pattern from the last post, actually wired into an Express route — connection, helper, invalidation, and the failure handling most tutorials skip.

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 one-sentence version
One Redis client created at boot, one small helper function that checks-then-falls-back, and explicit cache deletes on every write — that’s almost the entire pattern.

The building blocks, in order

Before the code, here’s the shape of what you’re actually building — click through each piece.

1
Connect
Step 1 of 7Setup

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

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

lib/redis.js
1import Redis from 'ioredis';
2
3export const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
4
5redis.on('error', (err) => {
6 // Log it, but don't crash the process — the fail-open logic below
7 // is what actually protects your routes from a Redis outage.
8 console.error('Redis connection error:', err.message);
9});
In practice
For local development, the fastest way to get a real Redis running is Docker: 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.

lib/cache.js
1import { redis } from './redis.js';
2
3export async function getOrSetCache(key, ttlSeconds, fetchFn) {
4 const cached = await redis.get(key);
5
6 if (cached) {
7 return JSON.parse(cached);
8 }
9
10 const fresh = await fetchFn();
11 await redis.set(key, JSON.stringify(fresh), 'EX', ttlSeconds);
12 return fresh;
13}
Common mistake
Forgetting 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.

routes/products.js
1import express from 'express';
2import { getOrSetCache } from '../lib/cache.js';
3import { db } from '../lib/db.js';
4
5const router = express.Router();
6
7router.get('/products/:id', async (req, res) => {
8 try {
9 const product = await getOrSetCache(
10 `product:${req.params.id}`,
11 300, // 5 minutes
12 () => db.products.findUnique({ where: { id: req.params.id } })
13 );
14
15 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});
22
23export 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.

routes/products.js
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 });
6
7 await redis.del(`product:${req.params.id}`);
8
9 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.

lib/cache.js
1export async function getOrSetCache(key, ttlSeconds, fetchFn) {
2 let cached = null;
3
4 try {
5 cached = await redis.get(key);
6 } catch (err) {
7 console.error('Redis unavailable, skipping cache read:', err.message);
8 }
9
10 if (cached) return JSON.parse(cached);
11
12 const fresh = await fetchFn();
13
14 // Don't await the write — a slow or failing cache write should
15 // never delay or break the actual response.
16 redis
17 .set(key, JSON.stringify(fresh), 'EX', ttlSeconds)
18 .catch((err) => console.error('Redis write failed:', err.message));
19
20 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.

lib/cacheMiddleware.js
1export function cacheRoute(ttlSeconds) {
2 return async (req, res, next) => {
3 const key = `route:${req.originalUrl}`;
4
5 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 }
11
12 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 };
17
18 next();
19 };
20}
21
22// 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.

lib/cache.js
1function withJitter(baseSeconds, maxJitterSeconds = 30) {
2 return baseSeconds + Math.floor(Math.random() * maxJitterSeconds);
3}
4
5await 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.

lib/rateLimit.js
1export async function isRateLimited(userId, limit = 100, windowSeconds = 60) {
2 const key = `ratelimit:${userId}`;
3 const count = await redis.incr(key);
4
5 if (count === 1) {
6 // Only the request that just created the key sets its expiry.
7 await redis.expire(key, windowSeconds);
8 }
9
10 return count > limit;
11}
12
13app.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
Common mistake
Treating Redis like a SQL connection pool and creating a new client per request. Redis connections are cheap to keep open and expensive to keep re-establishing — one shared client, created once, is the correct pattern.
In practice
Start with just the 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.