Redis is the most widely used in-memory data store in backend development. For a Node.js API, it solves three major problems: caching expensive database queries, storing sessions without hitting the database on every request, and enforcing rate limits at high throughput. This guide covers all three, with working code you can drop into a real project.
Why Redis?
A database query that takes 200ms can be served in under 1ms from Redis — because Redis stores data in RAM and uses data structures optimised for speed. For endpoints that serve the same data to many users (product listings, blog posts, leaderboards), caching in Redis is the highest-ROI performance optimization available.
Redis is not a database replacement. It's a cache layer that sits in front of your database. The database remains the source of truth; Redis holds frequently-accessed copies that expire automatically.
Installing Redis and ioredis
# Run Redis with Docker (easiest for development)
docker run -d --name redis -p 6379:6379 redis:7-alpine
# Or install locally (Ubuntu/Debian)
sudo apt install redis-server
sudo systemctl start redis
redis-cli ping # should return PONG
# Install the Node.js client
npm install ioredis
npm install -D @types/ioredis # if using TypeScript
Connecting to Redis
// lib/redis.ts — singleton client
import Redis from 'ioredis'
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASS || undefined,
retryStrategy: (times) => Math.min(times * 100, 3000), // retry with backoff
maxRetriesPerRequest: 3,
lazyConnect: true,
})
redis.on('error', (err) => console.error('Redis error:', err))
redis.on('connect', () => console.log('Redis connected'))
export default redis
The Cache-Aside Pattern
Cache-aside (also called lazy-loading) is the most common caching pattern:
- Check if the data is in Redis
- If yes (cache hit), return it immediately
- If no (cache miss), fetch from the database, store in Redis, then return
import { prisma } from './lib/prisma'
import redis from './lib/redis'
async function getPost(id: number) {
const cacheKey = `post:${id}`
// 1. Check cache
const cached = await redis.get(cacheKey)
if (cached) {
return JSON.parse(cached) // cache hit — return immediately
}
// 2. Cache miss — fetch from DB
const post = await prisma.post.findUnique({
where: { id },
include: { author: true, tags: true },
})
if (!post) return null
// 3. Store in cache with 10-minute TTL
await redis.setex(cacheKey, 600, JSON.stringify(post))
return post
}
Building a Reusable Cache Helper
Instead of repeating the cache-aside pattern everywhere, wrap it in a utility:
// lib/cache.ts
import redis from './redis'
export async function cached<T>(
key: string,
ttl: number, // seconds
fetcher: () => Promise<T>
): Promise<T> {
const hit = await redis.get(key)
if (hit) return JSON.parse(hit) as T
const data = await fetcher()
await redis.setex(key, ttl, JSON.stringify(data))
return data
}
export async function invalidate(key: string | string[]) {
if (Array.isArray(key)) {
if (key.length > 0) await redis.del(...key)
} else {
await redis.del(key)
}
}
export async function invalidatePattern(pattern: string) {
const keys = await redis.keys(pattern) // e.g., "post:*"
if (keys.length > 0) await redis.del(...keys)
}
Now your route handlers are clean:
import { cached, invalidate, invalidatePattern } from './lib/cache'
import express from 'express'
const app = express()
app.use(express.json())
// Cached list
app.get('/posts', async (req, res) => {
const posts = await cached('posts:all', 300, () =>
prisma.post.findMany({ where: { published: true } })
)
res.json(posts)
})
// Cached single post
app.get('/posts/:id', async (req, res) => {
const post = await cached(`post:${req.params.id}`, 600, () =>
prisma.post.findUnique({ where: { id: Number(req.params.id) } })
)
if (!post) return res.status(404).json({ error: 'Not found' })
res.json(post)
})
// On update — invalidate the affected caches
app.patch('/posts/:id', async (req, res) => {
const id = Number(req.params.id)
const post = await prisma.post.update({ where: { id }, data: req.body })
await invalidate([`post:${id}`, 'posts:all']) // clear both
res.json(post)
})
// On delete — invalidate all post caches
app.delete('/posts/:id', async (req, res) => {
const id = Number(req.params.id)
await prisma.post.delete({ where: { id } })
await invalidate(`post:${id}`)
await invalidatePattern('posts:*')
res.status(204).end()
})
Cache Invalidation Strategy
Cache invalidation is the hard part of caching. There are three main approaches:
1. TTL-Based (Eventual Consistency)
Set a short TTL and let data expire naturally. Stale data exists for at most ttl seconds. Works well when slight staleness is acceptable (product listings, blog posts). The simplest approach — no explicit invalidation needed.
2. Explicit Invalidation on Write
Delete the cache key whenever data changes (shown above). Ensures immediate consistency but requires you to track which cache keys need clearing for each mutation. Use a naming convention like resource:id and resource:list to make this manageable.
3. Write-Through Cache
// Update both DB and cache atomically
async function updatePost(id: number, data: Partial<Post>) {
const post = await prisma.post.update({ where: { id }, data })
await redis.setex(`post:${id}`, 600, JSON.stringify(post)) // write to cache immediately
await invalidate('posts:all')
return post
}
Session Storage with Redis
Storing sessions in Redis instead of memory means sessions survive server restarts and work across multiple server instances:
npm install express-session connect-redis
import session from 'express-session'
import { RedisStore } from 'connect-redis'
import redis from './lib/redis'
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms
},
}))
// Set session data
app.post('/login', async (req, res) => {
const user = await validateCredentials(req.body)
req.session.userId = user.id
res.json({ success: true })
})
// Read session data
app.get('/me', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Not logged in' })
res.json({ userId: req.session.userId })
})
Rate Limiting with Redis
Redis's atomic increment operations make it ideal for rate limiting:
// middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express'
import redis from '../lib/redis'
interface RateLimitOptions {
windowSeconds: number // time window in seconds
maxRequests: number // max requests per window
keyPrefix?: string
}
export function rateLimiter(opts: RateLimitOptions) {
return async (req: Request, res: Response, next: NextFunction) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown'
const prefix = opts.keyPrefix || 'ratelimit'
const key = `${prefix}:${ip}`
const count = await redis.incr(key)
if (count === 1) {
// First request in this window — set expiry
await redis.expire(key, opts.windowSeconds)
}
const remaining = Math.max(0, opts.maxRequests - count)
res.setHeader('X-RateLimit-Limit', opts.maxRequests)
res.setHeader('X-RateLimit-Remaining', remaining)
if (count > opts.maxRequests) {
const ttl = await redis.ttl(key)
res.setHeader('Retry-After', ttl)
return res.status(429).json({
error: 'Too many requests',
retryIn: `${ttl} seconds`,
})
}
next()
}
}
// Apply per route
app.post('/auth/login',
rateLimiter({ windowSeconds: 60, maxRequests: 5, keyPrefix: 'login' }),
loginHandler
)
app.use('/api',
rateLimiter({ windowSeconds: 60, maxRequests: 100 }),
apiRouter
)
Redis Data Types Beyond Strings
// Sorted set — leaderboard
await redis.zadd('leaderboard', 1500, 'alice', 1200, 'bob', 900, 'charlie')
const top10 = await redis.zrevrange('leaderboard', 0, 9, 'WITHSCORES')
// Increment score
await redis.zincrby('leaderboard', 100, 'charlie')
// Hash — user profile (update individual fields without re-serializing)
await redis.hset(`user:42`, { name: 'Alice', email: 'alice@example.com', plan: 'pro' })
const email = await redis.hget(`user:42`, 'email')
const profile = await redis.hgetall(`user:42`)
// List — activity feed
await redis.lpush(`feed:42`, JSON.stringify({ type: 'post', postId: 1 }))
await redis.ltrim(`feed:42`, 0, 49) // keep only 50 most recent
const feed = await redis.lrange(`feed:42`, 0, 19) // get latest 20
// Set — tracking online users (unique)
await redis.sadd('online_users', userId)
await redis.expire('online_users', 300) // 5-minute TTL per session ping
const count = await redis.scard('online_users') // count online
Monitoring Redis in Production
# Connect to Redis CLI
redis-cli -h your-host -p 6379 -a your-password
# Key stats
INFO memory # memory usage
INFO stats # hit rate, commands/sec
INFO clients # connected clients
# Monitor all commands in real time (use sparingly)
MONITOR
# Count keys by pattern
redis-cli --scan --pattern "post:*" | wc -l
# Check TTL on a key
TTL post:42
# Flush specific keys (not entire DB!)
redis-cli --scan --pattern "posts:*" | xargs redis-cli del
Production Checklist
- Set
maxmemoryandmaxmemory-policy allkeys-lruinredis.conf— prevents Redis from using unlimited RAM and evicts least-recently-used keys when full - Enable persistence (
appendonly yes) if session data or queued jobs cannot be lost on restart - Use a password (
requirepass yourpassword) and bind to localhost or private network only - Use Upstash (serverless Redis) or Redis Cloud for managed hosting — eliminates ops overhead
- Never store sensitive data (raw passwords, PII) in Redis without encryption — memory is not encrypted at rest by default
Summary
Adding Redis caching to a Node.js API is one of the highest-impact performance improvements you can make. Start with the cache-aside pattern on your most-queried endpoints, set conservative TTLs, and add explicit invalidation on writes. Then layer on session storage and rate limiting. The entire setup from zero to working cache takes under two hours for a typical Express or Fastify API.
CREESOL