Redis Caching

Difficulty: Intermediate

Question

How do you implement caching with Redis in a Node.js application? Explain caching strategies and cache invalidation.

Answer

Redis is an in-memory data store used as a cache, session store, message broker, and rate limiter in Node.js applications. It stores data as key-value pairs with optional expiration (TTL), providing sub-millisecond read times.

The cache-aside (lazy loading) pattern is the most common: check Redis first, if cache miss then query the database, store the result in Redis with a TTL, and return it. This reduces database load significantly for frequently accessed data.

Cache invalidation is the hardest part of caching. Strategies include: TTL-based expiration (simplest), write-through (update cache on every write), and event-based invalidation (invalidate when data changes). Choose based on data freshness requirements.

Redis also supports data structures beyond strings: hashes (object fields), lists (queues), sets (unique collections), sorted sets (leaderboards), and pub/sub for real-time messaging. These make Redis useful far beyond simple caching.

Code examples

Redis Setup and Basic Operations

const Redis = require('ioredis');

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD,
  retryStrategy: (times) => Math.min(times * 50, 2000),
});

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

// Basic operations
await redis.set('key', 'value');             // Set string
await redis.set('key', 'value', 'EX', 3600); // Set with 1hr TTL
const val = await redis.get('key');           // Get string

// JSON objects (store as string)
await redis.set('user:1', JSON.stringify({ name: 'Alice', role: 'admin' }), 'EX', 600);
const user = JSON.parse(await redis.get('user:1'));

// Hash (object fields)
await redis.hset('user:2', 'name', 'Bob', 'email', 'bob@test.com');
const name = await redis.hget('user:2', 'name');
const all = await redis.hgetall('user:2');

// Delete
await redis.del('key', 'user:1');

ioredis is the standard Redis client for Node.js. Store objects as JSON strings with TTL. Use hashes for partial updates without serializing the entire object.

Cache-Aside Pattern

async function getCachedUser(userId) {
  const cacheKey = `user:${userId}`;

  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    console.log('Cache HIT');
    return JSON.parse(cached);
  }

  // 2. Cache miss - query database
  console.log('Cache MISS');
  const user = await prisma.user.findUnique({
    where: { id: userId },
    include: { profile: true, posts: { take: 5 } },
  });

  if (!user) return null;

  // 3. Store in cache with TTL
  await redis.set(cacheKey, JSON.stringify(user), 'EX', 600); // 10 min

  return user;
}

// Cache-aside middleware for Express
function cacheMiddleware(keyPrefix, ttl = 300) {
  return async (req, res, next) => {
    const key = `${keyPrefix}:${req.originalUrl}`;
    const cached = await redis.get(key);

    if (cached) {
      return res.json(JSON.parse(cached));
    }

    // Override res.json to cache the response
    const originalJson = res.json.bind(res);
    res.json = (data) => {
      redis.set(key, JSON.stringify(data), 'EX', ttl);
      return originalJson(data);
    };
    next();
  };
}

app.get('/api/products', cacheMiddleware('products', 120), getProducts);

Cache-aside: check cache first, query DB on miss, store result. The middleware pattern caches entire API responses transparently.

Cache Invalidation Strategies

// Strategy 1: Invalidate on write
async function updateUser(userId, data) {
  const user = await prisma.user.update({
    where: { id: userId },
    data,
  });

  // Invalidate specific cache
  await redis.del(`user:${userId}`);
  // Invalidate list caches that might contain this user
  const keys = await redis.keys('users:list:*');
  if (keys.length) await redis.del(...keys);

  return user;
}

// Strategy 2: Write-through (update cache AND db)
async function updateUserWriteThrough(userId, data) {
  const user = await prisma.user.update({
    where: { id: userId },
    data,
  });
  // Update cache with fresh data
  await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 600);
  return user;
}

// Strategy 3: Cache tags for bulk invalidation
async function invalidateTag(tag) {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length) {
    await redis.del(...keys);
    await redis.del(`tag:${tag}`);
  }
}

// When caching, register the key under a tag
async function cacheWithTag(key, value, ttl, tags) {
  await redis.set(key, JSON.stringify(value), 'EX', ttl);
  for (const tag of tags) {
    await redis.sadd(`tag:${tag}`, key);
  }
}

Invalidate-on-write deletes stale cache. Write-through updates the cache. Tags let you invalidate groups of related keys efficiently.

Key points

Concepts covered

Redis, Cache-Aside, TTL, Cache Invalidation, Session Store