Redis Caching Guide 2026 — Improve API Performance 10x
Advertisement
Introduction
Why This Matters
A database query that takes 50ms takes 0.1ms from Redis. Caching the right data in Redis can reduce database load by 90% and cut API response times from hundreds of milliseconds to single digits. In 2026, Redis 8 ships with vector search built-in, making it a swiss-army tool for modern backends.
Redis Client Setup
npm install ioredis
npm install -D @types/ioredis// src/lib/redis.ts
import Redis from 'ioredis'
const globalForRedis = globalThis as unknown as { redis: Redis }
export const redis =
globalForRedis.redis ??
new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
retryStrategy: (times) => Math.min(times * 100, 3000),
lazyConnect: false,
})
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redis
redis.on('error', (err) => console.error('Redis error:', err))
redis.on('connect', () => console.log('Redis connected'))Cache-Aside Pattern
The most common caching strategy — read from cache first, fall back to database:
// src/lib/cache.ts
import { redis } from './redis'
const DEFAULT_TTL = 3600 // 1 hour
export async function withCache<T>(
key: string,
fetcher: () => Promise<T>,
ttl = DEFAULT_TTL
): Promise<T> {
const cached = await redis.get(key)
if (cached) {
return JSON.parse(cached) as T
}
const data = await fetcher()
await redis.setex(key, ttl, JSON.stringify(data))
return data
}
export async function invalidateCache(pattern: string) {
const keys = await redis.keys(pattern)
if (keys.length > 0) {
await redis.del(...keys)
}
}// Usage in an API route
export async function getPost(slug: string) {
return withCache(
`post:${slug}`,
() => prisma.post.findUnique({ where: { slug }, include: { author: true } }),
3600 // cache for 1 hour
)
}
export async function updatePost(id: string, data: UpdatePostInput) {
const post = await prisma.post.update({ where: { id }, data })
// Invalidate specific post and list caches
await invalidateCache(`post:${post.slug}`)
await invalidateCache('posts:*')
return post
}Rate Limiting with Redis
Sliding window rate limiter:
// src/middleware/rateLimit.ts
import { redis } from '../lib/redis'
export async function checkRateLimit(
identifier: string,
limit: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetAt: number }> {
const key = `rate:${identifier}`
const now = Date.now()
const windowStart = now - windowSeconds * 1000
// Atomic sliding window using sorted sets
const [, , count] = await redis
.multi()
.zremrangebyscore(key, 0, windowStart)
.zadd(key, now, `${now}-${Math.random()}`)
.zcard(key)
.expire(key, windowSeconds)
.exec() as [any, any, [null, number], any]
const currentCount = count[1]
const allowed = currentCount <= limit
const resetAt = Math.floor((now + windowSeconds * 1000) / 1000)
return { allowed, remaining: Math.max(0, limit - currentCount), resetAt }
}// Usage in Fastify middleware
app.addHook('preHandler', async (request, reply) => {
const ip = request.ip
const { allowed, remaining, resetAt } = await checkRateLimit(ip, 100, 60)
reply.header('X-RateLimit-Limit', 100)
reply.header('X-RateLimit-Remaining', remaining)
reply.header('X-RateLimit-Reset', resetAt)
if (!allowed) {
return reply.status(429).send({ error: 'Too Many Requests' })
}
})Session Storage
// src/lib/session.ts
import { redis } from './redis'
import { randomUUID } from 'crypto'
const SESSION_TTL = 7 * 24 * 60 * 60 // 7 days
export async function createSession(userId: string): Promise<string> {
const sessionId = randomUUID()
await redis.setex(
`session:${sessionId}`,
SESSION_TTL,
JSON.stringify({ userId, createdAt: Date.now() })
)
return sessionId
}
export async function getSession(sessionId: string) {
const data = await redis.get(`session:${sessionId}`)
if (!data) return null
// Sliding expiry — reset TTL on access
await redis.expire(`session:${sessionId}`, SESSION_TTL)
return JSON.parse(data)
}
export async function deleteSession(sessionId: string) {
await redis.del(`session:${sessionId}`)
}Pub/Sub for Real-Time Events
// Publisher
const publisher = new Redis(process.env.REDIS_URL!)
const subscriber = new Redis(process.env.REDIS_URL!)
// Publish an event
await publisher.publish('notifications', JSON.stringify({
type: 'NEW_MESSAGE',
userId: 'user_123',
data: { content: 'Hello!', from: 'alice' },
}))
// Subscribe and handle
await subscriber.subscribe('notifications')
subscriber.on('message', (channel, message) => {
const event = JSON.parse(message)
console.log(`Event on ${channel}:`, event)
// Notify connected WebSocket clients
broadcastToUser(event.userId, event)
})Common Mistakes
- Using
redis.keys('*')in production — scans the entire keyspace and blocks the server - Not setting TTLs — stale data accumulates until Redis runs out of memory
- Caching mutable data without an invalidation strategy — users see stale content indefinitely
- Sharing one Redis connection between the publisher and subscriber — pub/sub requires separate connections
- Storing entire database result sets — cache only what is expensive and frequently read
Best Practices
- Always set a TTL on cached keys — never use
SETwithoutEXorPX - Use
SCANinstead ofKEYSfor pattern-based key discovery in production - Namespace all keys with a prefix (
post:,session:,rate:) to avoid collisions - Use Redis pipelining or
multi()for multiple commands to reduce round trips - Monitor cache hit rate — a hit rate below 80% means your caching strategy needs adjustment
Key Takeaways
- Redis responds in under 1ms — 50–100x faster than a PostgreSQL query for the same data
- Cache-aside is the most flexible strategy: read cache first, fall back to DB, write on miss
- Always invalidate related cache keys after mutations to prevent stale data
- Sorted sets with timestamps enable accurate sliding-window rate limiting in Redis
- Session storage in Redis with sliding TTL is simpler and more scalable than database sessions
- Pub/Sub requires dedicated publisher and subscriber connections — never reuse one connection for both
SCANis safe for production key iteration;KEYSblocks the event loop and must be avoided- Redis 8 includes vector search, making it usable for semantic search and AI retrieval workflows
Advertisement
Related reading
Redis Eviction Causing Chaos — When Your Cache Turns on You6 min readRedis in 2026 — Beyond Caching to the Multi-Model Database9 min readCache Stampede — How to Prevent Thundering Herd on Cache Expiry6 min readRedis with Node.js — Caching, Sessions, and Pub/Sub 20267 min readCache Invalidation Hell — The Second Hardest Problem in Computer Science6 min readDB Connection Pool Exhaustion — Why Your App Hangs at Peak Load6 min read