Redis Caching Guide 2026 — Improve API Performance 10x

Sanjeev SharmaSanjeev Sharma
5 min read

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 &lt;= 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 SET without EX or PX
  • Use SCAN instead of KEYS for 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
  • SCAN is safe for production key iteration; KEYS blocks the event loop and must be avoided
  • Redis 8 includes vector search, making it usable for semantic search and AI retrieval workflows

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading