TechLead
Lesson 15 of 22
5 min read
Performance Engineering

Redis Caching

Implement Redis for session caching, rate limiting, leaderboards, and distributed caching patterns

Redis as a Performance Layer

Redis is an in-memory data store that provides sub-millisecond read/write latency. As a caching layer, Redis sits between your application and your database, dramatically reducing database load and response times. Beyond simple key-value caching, Redis supports data structures like sorted sets, lists, and streams that enable powerful patterns like rate limiting, real-time leaderboards, and pub/sub messaging.

When to Use Redis

  • Session storage: Fast, shared sessions across server instances
  • API response caching: Cache expensive query results for sub-ms reads
  • Rate limiting: Sliding window counters for API rate limits
  • Real-time features: Leaderboards (sorted sets), queues (lists), pub/sub
  • Distributed locks: Prevent concurrent execution in distributed systems

Redis Client Setup

// lib/redis.ts — Redis client with connection pooling
import { Redis } from 'ioredis';

// Singleton pattern for connection reuse
let redis: Redis | null = null;

export function getRedis(): Redis {
  if (!redis) {
    redis = new Redis({
      host: process.env.REDIS_HOST || 'localhost',
      port: parseInt(process.env.REDIS_PORT || '6379'),
      password: process.env.REDIS_PASSWORD,
      db: 0,
      maxRetriesPerRequest: 3,
      retryStrategy(times) {
        const delay = Math.min(times * 50, 2000);
        return delay;
      },
      // Connection pool settings
      lazyConnect: true,
      keepAlive: 30000,
    });

    redis.on('error', (err) => console.error('Redis error:', err));
    redis.on('connect', () => console.log('Redis connected'));
  }
  return redis;
}

// For Vercel KV (serverless-friendly Redis)
import { kv } from '@vercel/kv';

// Auto-managed connection, no pooling needed
await kv.set('key', 'value', { ex: 3600 });

Caching Patterns with Redis

// Cache-aside pattern with serialization
const redis = getRedis();

async function getCachedData<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds: number = 3600
): Promise<T> {
  // 1. Try cache first
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached) as T;
  }

  // 2. Cache miss — fetch from source
  const data = await fetcher();

  // 3. Store in cache (non-blocking)
  redis.set(key, JSON.stringify(data), 'EX', ttlSeconds).catch(console.error);

  return data;
}

// Usage in API route
export async function GET(request: NextRequest) {
  const products = await getCachedData(
    'products:featured',
    () => prisma.product.findMany({ where: { featured: true }, take: 10 }),
    300 // 5 min TTL
  );
  return NextResponse.json(products);
}

// Cache with hash for structured data
async function cacheUserProfile(userId: string) {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  if (!user) return null;

  // Store as a hash — individual fields can be read/updated
  await redis.hset(`user:${userId}`, {
    name: user.name,
    email: user.email,
    plan: user.plan,
    lastActive: new Date().toISOString(),
  });
  await redis.expire(`user:${userId}`, 3600);

  return user;
}

// Read single field without deserializing entire object
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, 'plan');
}

Rate Limiting with Redis

// Sliding window rate limiter
async function rateLimit(
  identifier: string,
  maxRequests: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
  const redis = getRedis();
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowMs = windowSeconds * 1000;

  // Use a sorted set with timestamps as scores
  const pipeline = redis.pipeline();
  // Remove entries outside the window
  pipeline.zremrangebyscore(key, 0, now - windowMs);
  // Add current request
  pipeline.zadd(key, now, `${now}-${Math.random()}`);
  // Count requests in window
  pipeline.zcard(key);
  // Set expiry on the key
  pipeline.expire(key, windowSeconds);

  const results = await pipeline.exec();
  const requestCount = results?.[2]?.[1] as number || 0;

  return {
    allowed: requestCount <= maxRequests,
    remaining: Math.max(0, maxRequests - requestCount),
    resetIn: windowSeconds,
  };
}

// Middleware for rate limiting
// middleware.ts
export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.headers.get('x-forwarded-for') || 'unknown';
    const result = await rateLimit(ip, 100, 60); // 100 req/min

    if (!result.allowed) {
      return NextResponse.json(
        { error: 'Too many requests' },
        {
          status: 429,
          headers: {
            'Retry-After': String(result.resetIn),
            'X-RateLimit-Remaining': String(result.remaining),
          },
        }
      );
    }
  }
}

Redis Performance Tips

  • Use pipelines: Batch multiple commands into a single round trip
  • Set TTLs on everything: Prevent memory leaks from forgotten keys
  • Use appropriate data structures: Hashes for objects, sorted sets for rankings, lists for queues
  • Monitor memory: Track Redis memory usage and eviction rates
  • Use connection pooling: Reuse connections instead of creating new ones per request

Continue Learning