Redis with Node.js — Caching, Sessions, and Pub/Sub 2026
Advertisement
Introduction
Why This Matters
Redis is the Swiss Army knife of backend infrastructure. It handles caching (sub-millisecond reads), distributed sessions (shared across Node.js instances), rate limiting (atomic increments), real-time pub/sub messaging, job queues, and leaderboards — all with a single dependency. In 2026, Redis (or its drop-in compatible cloud equivalents like Valkey, DragonflyDB, and AWS ElastiCache) is present in virtually every production Node.js architecture.
Installation and Connection
# Official Redis client (Node.js 16+)
npm install redis
# Or ioredis (more features, slightly different API)
npm install ioredis// src/redis/client.ts — official redis package
import { createClient, RedisClientType } from 'redis';
let redisClient: RedisClientType;
export async function getRedisClient(): Promise<RedisClientType> {
if (redisClient) return redisClient;
redisClient = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
socket: {
reconnectStrategy: (retries) => Math.min(retries * 100, 3000),
connectTimeout: 5000,
},
});
redisClient.on('error', (err) => console.error('Redis error:', err));
redisClient.on('connect', () => console.log('Redis connected'));
redisClient.on('reconnecting', () => console.log('Redis reconnecting...'));
await redisClient.connect();
return redisClient;
}
// Graceful shutdown
process.on('SIGTERM', async () => {
if (redisClient) await redisClient.quit();
});Caching Patterns
Cache-Aside (Lazy Loading)
// src/cache/users.ts
import { getRedisClient } from '../redis/client';
const TTL = 300; // 5 minutes
export async function getCachedUser(userId: string) {
const redis = await getRedisClient();
const cacheKey = `user:${userId}`;
// Check cache first
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// Cache miss — fetch from DB
const user = await db.users.findById(userId);
if (!user) return null;
// Store in cache with TTL
await redis.setEx(cacheKey, TTL, JSON.stringify(user));
return user;
}
export async function invalidateUserCache(userId: string) {
const redis = await getRedisClient();
await redis.del(`user:${userId}`);
}Write-Through Cache
// Write to both DB and cache on update
export async function updateUser(
userId: string,
data: { name?: string; email?: string }
) {
const redis = await getRedisClient();
const updatedUser = await db.users.update(userId, data);
// Immediately update cache with fresh data
await redis.setEx(`user:${userId}`, TTL, JSON.stringify(updatedUser));
return updatedUser;
}Memoize with Hash
For objects with multiple fields, Redis hashes are more efficient than serializing to JSON.
export async function getUserProfile(userId: string) {
const redis = await getRedisClient();
const key = `profile:${userId}`;
const cached = await redis.hGetAll(key);
if (Object.keys(cached).length > 0) {
return cached; // Redis hashes return Record<string, string>
}
const profile = await db.profiles.findByUserId(userId);
if (!profile) return null;
await redis.hSet(key, {
id: String(profile.id),
bio: profile.bio,
userId: String(profile.userId),
});
await redis.expire(key, TTL);
return profile;
}Session Management
Redis is the standard distributed session store for Node.js. Every instance reads from the same Redis cluster, so sessions survive restarts and horizontal scaling.
npm install express-session connect-redis// src/middleware/session.ts
import session from 'express-session';
import { RedisStore } from 'connect-redis';
import { getRedisClient } from '../redis/client';
export async function createSessionMiddleware() {
const redis = await getRedisClient();
return session({
store: new RedisStore({
client: redis,
prefix: 'sess:',
ttl: 86_400, // 24 hours
}),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax',
},
});
}Rate Limiting with Redis
Atomic INCR and EXPIRE commands make Redis ideal for distributed rate limiting.
// src/middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express';
import { getRedisClient } from '../redis/client';
interface RateLimitConfig {
windowMs: number; // Time window in ms
max: number; // Max requests per window
keyPrefix?: string;
}
export function createRateLimiter({ windowMs, max, keyPrefix = 'rl' }: RateLimitConfig) {
return async (req: Request, res: Response, next: NextFunction) => {
const redis = await getRedisClient();
const identifier = req.ip ?? 'unknown';
const key = `${keyPrefix}:${identifier}`;
const windowSecs = Math.floor(windowMs / 1000);
// Atomic increment — safe for concurrent requests
const count = await redis.incr(key);
if (count === 1) {
// First request in window — set expiry
await redis.expire(key, windowSecs);
}
const ttl = await redis.ttl(key);
res.setHeader('X-RateLimit-Limit', max);
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count));
res.setHeader('X-RateLimit-Reset', Date.now() + ttl * 1000);
if (count > max) {
return res.status(429).json({
error: 'Too many requests',
retryAfter: ttl,
});
}
next();
};
}
// Usage
app.use('/api/', createRateLimiter({ windowMs: 60_000, max: 100 }));
app.use('/api/auth/', createRateLimiter({ windowMs: 60_000, max: 10, keyPrefix: 'auth-rl' }));Pub/Sub for Real-Time Messaging
Redis pub/sub decouples publishers from subscribers across multiple Node.js processes.
// src/pubsub/index.ts
import { createClient } from 'redis';
// Separate clients for pub and sub — subscribed clients cannot issue other commands
const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = publisher.duplicate();
await Promise.all([publisher.connect(), subscriber.connect()]);
// Publish an event
export async function publishEvent(channel: string, data: unknown) {
await publisher.publish(channel, JSON.stringify(data));
}
// Subscribe to a channel
export async function subscribeToChannel(
channel: string,
handler: (data: unknown) => void
) {
await subscriber.subscribe(channel, (message) => {
try {
handler(JSON.parse(message));
} catch (err) {
console.error(`Error processing message from ${channel}:`, err);
}
});
}
// Example: broadcast user events to all server instances
await subscribeToChannel('user:events', (event: unknown) => {
const { type, userId } = event as { type: string; userId: string };
if (type === 'logout') {
// Invalidate in-memory caches across all instances
localCache.delete(`user:${userId}`);
}
});Distributed Locks with SETNX
Prevent race conditions in distributed systems using Redis-based locks.
export async function withLock<T>(
lockKey: string,
ttlSeconds: number,
fn: () => Promise<T>
): Promise<T | null> {
const redis = await getRedisClient();
const lockValue = crypto.randomUUID();
// SET key value NX EX ttl — atomic lock acquisition
const acquired = await redis.set(lockKey, lockValue, {
NX: true, // Only set if key does not exist
EX: ttlSeconds,
});
if (!acquired) {
console.log(`Could not acquire lock: ${lockKey}`);
return null;
}
try {
return await fn();
} finally {
// Only delete if we still own the lock (check value)
const currentValue = await redis.get(lockKey);
if (currentValue === lockValue) {
await redis.del(lockKey);
}
}
}
// Usage
const result = await withLock('payment:user-123', 30, async () => {
return processPayment(userId, amount);
});Common Mistakes
Mistake 1 — Using the same Redis client for pub/sub: a subscribed Redis client is in a blocking state and cannot run other commands. Always use a separate client for subscriptions.
Mistake 2 — Not setting TTLs on cached keys: without TTL, cache keys accumulate indefinitely and Redis runs out of memory. Always use setEx or call expire.
Mistake 3 — Caching mutable data without invalidation: stale cache is worse than no cache. Build cache invalidation into every write path.
Mistake 4 — Storing large objects in Redis: Redis is not a document store. Keep values small — store IDs and reference them, or store only the fields you actually read.
Best Practices
- Use key prefixes (
user:,sess:,rl:) to namespace keys and make memory inspection easier withSCAN. - Set
maxmemory-policy allkeys-lruin Redis config so it evicts least-recently-used keys when memory is full, acting as a proper cache. - Use Redis pipelines (
redis.multi()) for multiple related commands to reduce round-trip latency. - Monitor
redis.info('stats')in production — trackkeyspace_hitsvskeyspace_missesto measure cache effectiveness. - Use Redis Cluster or Redis Sentinel in production for high availability — a single Redis instance is a single point of failure.
Key Takeaways
- Redis is an in-memory data store capable of sub-millisecond reads, making it the standard caching layer for Node.js applications.
- The cache-aside pattern is the most common: read from cache, fall back to DB on miss, then populate cache.
- Pub/Sub requires separate Redis client instances for publisher and subscriber — subscribed clients cannot issue normal commands.
INCR+EXPIREis the atomic pattern for distributed rate limiting without race conditions.SET key value NX EX ttlis the standard distributed lock pattern — check the lock value before releasing to prevent stealing.- Always set TTLs on cached keys to prevent unbounded memory growth.
- Use
maxmemory-policy allkeys-lruto configure Redis as a proper LRU cache that evicts old keys automatically. - In multi-instance Node.js deployments, Redis sessions ensure every instance reads from the same session state.
Advertisement