Adding Redis to Your Node.js App: Caching, Rate Limiting, and Session Storage
Four production patterns with real working code — from API response caching to atomic rate limiting and cross-instance pub/sub
Senior Developer

Most developers reach for Redis when their app starts feeling slow and someone mentions "just add a cache." That is a fine reason to start. But Redis does a lot more than cache API responses, and understanding the other patterns — rate limiting, session storage, pub/sub — changes how you architect backends from the beginning rather than bolting things on later.
This guide covers the four most practical Redis patterns for a Node.js app, with real working code for each. The stack is Node.js, Express, and ioredis — the most capable Redis client for Node.
Setup
Run Redis locally with Docker:
docker run -d \
--name redis-dev \
-p 6379:6379 \
redis:7-alpineInstall the client:
npm install ioredisCreate a shared Redis client — one instance, used everywhere:
// src/lib/redis.js
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
// Exponential backoff — wait longer between each retry
const delay = Math.min(times * 50, 2000);
return delay;
},
// If Redis goes down, fail gracefully rather than crashing
enableOfflineQueue: false,
});
redis.on('error', (err) => {
console.error('Redis connection error:', err.message);
});
redis.on('connect', () => {
console.log('Redis connected');
});
export default redis;enableOfflineQueue: false is important for production. It means if Redis is temporarily unreachable, commands fail immediately rather than queuing up and flooding when the connection restores.
Pattern 1 — API Response Caching
The most common use case. Your app hits a slow database query or an external API. You cache the result in Redis so the second request (and every request after) takes milliseconds instead of seconds.
// src/middleware/cache.js
import redis from '../lib/redis.js';
/**
* Cache middleware factory
* @param {number} ttlSeconds - How long to cache the response
* @param {function} keyFn - Function to derive the cache key from req
*/
export function cacheMiddleware(ttlSeconds = 60, keyFn = (req) => `cache:${req.originalUrl}`) {
return async (req, res, next) => {
const key = keyFn(req);
try {
const cached = await redis.get(key);
if (cached) {
res.setHeader('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
} catch (err) {
// If Redis is down, just continue to the actual handler
console.error('Cache read error:', err.message);
}
// Intercept the response to cache it before sending
const originalJson = res.json.bind(res);
res.json = async (data) => {
try {
await redis.setex(key, ttlSeconds, JSON.stringify(data));
} catch (err) {
console.error('Cache write error:', err.message);
}
res.setHeader('X-Cache', 'MISS');
return originalJson(data);
};
next();
};
}Usage in your router:
import { cacheMiddleware } from '../middleware/cache.js';
// Cache all /products responses for 5 minutes
router.get('/products',
cacheMiddleware(300),
async (req, res) => {
const products = await db.query('SELECT * FROM products WHERE active = true');
res.json(products.rows);
}
);
// Cache per-user — key includes user ID
router.get('/profile',
authenticate,
cacheMiddleware(120, (req) => `cache:profile:${req.user.id}`),
async (req, res) => {
const profile = await db.query('SELECT * FROM users WHERE id = $1', [req.user.id]);
res.json(profile.rows[0]);
}
);
Cache invalidation — clear a cache entry when data changes:
// After updating a product, invalidate its cache
router.put('/products/:id', authenticate, async (req, res) => {
await db.query('UPDATE products SET ... WHERE id = $1', [req.params.id]);
// Invalidate specific key
await redis.del(`cache:/products/${req.params.id}`);
// Or use a pattern to clear all product list caches
const keys = await redis.keys('cache:/products*');
if (keys.length > 0) {
await redis.del(...keys);
}
res.json({ success: true });
});A note on redis.keys(): it blocks the Redis server while scanning. Fine for dev, not for production with large key spaces. Use SCAN instead for production pattern-based deletion.
Pattern 2 — Rate Limiting
Rate limiting without Redis falls apart the moment you run more than one server instance. In-memory rate limiters are per-process, so a user can bypass a 100 req/min limit by hitting different instances. Redis makes rate limits work across your entire fleet.
The sliding window counter approach is the most accurate:
// src/middleware/rateLimiter.js
import redis from '../lib/redis.js';
/**
* Sliding window rate limiter
* @param {number} maxRequests - Max requests allowed in the window
* @param {number} windowSeconds - Time window in seconds
* @param {function} identifierFn - Function to extract identifier from req
*/
export function rateLimiter(
maxRequests = 100,
windowSeconds = 60,
identifierFn = (req) => req.ip
) {
return async (req, res, next) => {
const identifier = identifierFn(req);
const key = `rate:${identifier}`;
try {
// Lua script ensures atomicity — increment and set TTL in one operation
const luaScript = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;
const current = await redis.eval(luaScript, 1, key, windowSeconds);
// Set headers so clients know their rate limit status
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', Math.max(0, maxRequests - current));
res.setHeader('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + windowSeconds);
if (current > maxRequests) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: windowSeconds,
});
}
} catch (err) {
// If Redis is down, fail open — let the request through
// Better to serve the request than to lock out all users because Redis is restarting
console.error('Rate limiter error:', err.message);
}
next();
};
}Usage — different limits for different routes:
import { rateLimiter } from '../middleware/rateLimiter.js';
// Strict limit on auth endpoints to prevent brute force
router.post('/auth/login',
rateLimiter(5, 60), // 5 attempts per minute per IP
loginHandler
);
// More generous limit for general API
router.use('/api',
rateLimiter(200, 60), // 200 requests per minute per IP
);
// Rate limit by authenticated user ID, not IP
router.use('/api/export',
authenticate,
rateLimiter(10, 3600, (req) => `user:${req.user.id}`), // 10 exports per hour
exportHandler
);
The Lua script is the key detail here. Without it, INCR and EXPIRE are two separate operations, and a process crash between them leaves keys that never expire — permanent rate limit blocks. Lua scripts execute atomically on the Redis server.
Pattern 3 — Session Storage
The default Express session stores sessions in memory. Fine for a single-server dev setup. Useless once you have two servers or restart your process and log out every user.
npm install express-session connect-redis// src/app.js
import session from 'express-session';
import RedisStore from 'connect-redis';
import redis from './lib/redis.js';
app.use(session({
store: new RedisStore({
client: redis,
prefix: 'sess:', // All session keys prefixed for easy identification
ttl: 86400, // Session TTL in seconds (24 hours)
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production', // HTTPS only in production
httpOnly: true, // Not accessible via JavaScript
maxAge: 24 * 60 * 60 * 1000, // 24 hours in ms
sameSite: 'strict',
},
name: 'sessionId', // Don't use the default 'connect.sid' — it fingerprints your stack
}));
Manual session operations you will need:
// Store data in session
req.session.userId = user.id;
req.session.role = user.role;
await req.session.save(); // Explicit save when you need it persisted immediately
// Read from session
const userId = req.session.userId;
// Destroy session on logout
router.post('/auth/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Logout failed' });
}
res.clearCookie('sessionId');
res.json({ success: true });
});
});
// Invalidate all sessions for a user (e.g. on password change)
async function invalidateAllUserSessions(userId) {
// Requires scanning for sessions with this user ID
// Better approach: store session ID -> userId mapping separately
const sessionKey = `user_sessions:${userId}`;
const sessionIds = await redis.smembers(sessionKey);
for (const sessionId of sessionIds) {
await redis.del(`sess:${sessionId}`);
}
await redis.del(sessionKey);
}Pattern 4 — Pub/Sub for Real-Time Events
When you need to broadcast an event to multiple parts of your application — or across multiple server instances — Redis pub/sub is the simplest solution that does not require a full message queue setup.
// src/lib/pubsub.js
import Redis from 'ioredis';
// Pub/sub requires separate client instances
// A client in subscribe mode cannot run regular commands
const publisher = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
});
const subscriber = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
});
export { publisher, subscriber };// Publishing an event (e.g. after a user action)
import { publisher } from '../lib/pubsub.js';
async function processOrder(orderId, userId) {
await db.query('UPDATE orders SET status = $1 WHERE id = $2', ['confirmed', orderId]);
// Notify any subscribers that this order was confirmed
await publisher.publish('order:confirmed', JSON.stringify({
orderId,
userId,
confirmedAt: new Date().toISOString(),
}));
}// Subscribing to events (e.g. to send a notification)
import { subscriber } from '../lib/pubsub.js';
subscriber.subscribe('order:confirmed', (err) => {
if (err) console.error('Subscribe error:', err);
});
subscriber.on('message', (channel, message) => {
if (channel === 'order:confirmed') {
const { orderId, userId } = JSON.parse(message);
// Send email, push notification, update analytics, etc.
sendOrderConfirmationEmail(userId, orderId);
}
});This is particularly useful when you have multiple server instances behind a load balancer — a request handled by instance A can publish an event that instance B's subscriber picks up and acts on.
Production Redis Config
A few things that matter before you ship:
Use a password:
# In your Redis Docker run command
docker run -d \
--name redis-prod \
-p 6379:6379 \
redis:7-alpine \
redis-server --requirepass yourStrongPasswordHereNever expose Redis port to the internet. Redis should only be reachable from your application servers, not from 0.0.0.0. On your VPS:
# Bind Redis to localhost only (in redis.conf or via command)
redis-server --bind 127.0.0.1 --requirepass yourpasswordSet a maxmemory policy so Redis does not crash when it fills up:
maxmemory 256mb
maxmemory-policy allkeys-lruallkeys-lru evicts the least recently used keys when memory is full — generally the right policy for a cache.
Monitor with redis-cli:
# Live command stats
redis-cli monitor
# Memory usage
redis-cli info memory
# Key count and hit/miss rate
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses|total_commands"The Mental Model
Redis is not a database replacement. It is a working memory layer — fast, temporary, and recoverable if it goes down (because your actual data is in Postgres). Design your cache patterns so that losing Redis entirely degrades performance but does not break correctness. If your app cannot function without the cache being warm, you have a deeper problem.
The patterns here — caching, rate limiting, sessions, pub/sub — cover the vast majority of what most Node.js backends need from Redis. Start with caching, add rate limiting on your auth endpoints, and expand from there as you need it.
Comments (0)
Login to post a comment.