Node.js
June 10, 20266 min read...
Node.jsJune 10, 20266 min read

Redis Caching Strategies for Node.js: From 50ms to 5ms

Master Redis caching to reduce database load and latency. This guide covers cache strategies, invalidation, serialization, and production patterns for high-scale Node.js applications.

Redis Caching Strategies for Node.js: From 50ms to 5ms

Caching is the single most effective performance optimization. A well-configured Redis cache can reduce database queries by 80-95%, turning 50ms responses into 5ms. But naive caching leads to stale data, memory bloat, and cache stampedes. This guide walks through battle-tested patterns from production systems handling 100k+ requests per second.

Setup: Redis Connection Pooling

Never create a new Redis connection per request. Use a singleton with ioredis:

import Redis from 'ioredis';

class RedisClient { static instance;

static getInstance() { if (!this.instance) { this.instance = new Redis({ host: process.env.REDIS_HOST, port: 6379, password: process.env.REDIS_PASSWORD, retryStrategy: (times) => Math.min(times * 50, 2000), maxRetriesPerRequest: 3, enableReadyCheck: true, lazyConnect: true, }); } return this.instance; } }

export const redis = RedisClient.getInstance();

Pattern #1: Cache-Aside (Lazy Loading)

Most common pattern. Application checks cache first, falls back to database, then writes to cache:

async function getUserById(id) {
  const cacheKey = `user:${id}`;

// 1. Try cache const cached = await redis.get(cacheKey); if (cached) { return JSON.parse(cached); }

// 2. Cache miss - get from DB const user = await db.query(‘SELECT * FROM users WHERE id = $1’, [id]);

// 3. Store in cache with TTL (5 minutes) await redis.setex(cacheKey, 300, JSON.stringify(user));

return user; }

Pros: Simple, works for read-heavy workloads.
Cons: First request after cache expiry is slow (cache stampede).

Pattern #2: Write-Through (Update Cache on Write)

When data updates, update both database and cache simultaneously:

async function updateUser(id, data) {
  // 1. Update database
  const updatedUser = await db.query(
    'UPDATE users SET name = $1 WHERE id = $2 RETURNING *',
    [data.name, id]
  );

// 2. Update cache synchronously const cacheKey = user:${id}; await redis.setex(cacheKey, 300, JSON.stringify(updatedUser.rows[0]));

// 3. Invalidate related caches await redis.del(user-profile:${id});

return updatedUser.rows[0]; }

Pattern #3: Cache Stampede Prevention (Mutex Lock)

When many requests simultaneously miss a just-expired cache, they all hit the database. Use Redis distributed lock to let only one request recompute:

async function getProductWithMutex(productId) {
  const cacheKey = `product:${productId}`;
  const lockKey = `lock:product:${productId}`;
  const ttl = 60; // 1 minute cache

// Try cache first let cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached);

// Try to acquire lock (NX = only if not exists, PX = expire in ms) const lockAcquired = await redis.set(lockKey, ‘locked’, ‘NX’, ‘PX’, 5000);

if (lockAcquired) { // This process recomputes const product = await db.query(‘SELECT * FROM products WHERE id = $1’, [productId]); await redis.setex(cacheKey, ttl, JSON.stringify(product)); await redis.del(lockKey); return product; } else { // Other processes wait and retry await sleep(100); return getProductWithMutex(productId); // Recursive retry } }

Pattern #4: Bulk/Batch Caching with Redis Pipeline

For APIs that return lists (e.g., 100 products), use pipelining to reduce round trips:

async function getProductsByIds(ids) {
  const pipeline = redis.pipeline();
  const keys = ids.map(id => `product:${id}`);

keys.forEach(key => pipeline.get(key)); const results = await pipeline.exec();

const cachedProducts = results .map(([err, data]) => err || !data ? null : JSON.parse(data)) .filter(p => p !== null);

const missingIds = ids.filter((id, idx) => !cachedProducts[idx]);

if (missingIds.length > 0) { const dbProducts = await db.query(‘SELECT * FROM products WHERE id = ANY($1)’, [missingIds]); // Cache missing products const multi = redis.multi(); dbProducts.forEach(p => multi.setex(product:${p.id}, 300, JSON.stringify(p))); await multi.exec();

return [...cachedProducts, ...dbProducts];

}

return cachedProducts; }

Serialization: JSON vs MessagePack

JSON is human-readable but verbose. For large payloads, use MessagePack (30% smaller, 50% faster):

import msgpack from '@msgpack/msgpack';

// Encode const buffer = msgpack.encode(largeObject); await redis.set(‘key’, buffer);

// Decode const buffer = await redis.getBuffer(‘key’); const obj = msgpack.decode(buffer);

Cache Invalidation Strategies

The hardest problem in caching. Common patterns:

  • Time-based (TTL): Simple, predictable staleness. Good for product catalogs, weather data.
  • Event-based invalidation: Emit events (e.g., 'user.updated') and have cache subscriber delete keys.
  • Versioned keys: user:v1:123, increment version on schema change.
  • Tag-based (Redis 6.2+): Store tags in a Set, delete all keys with a tag on update.
// Tag-based invalidation example
async function cacheWithTags(key, tags, data, ttl) {
  await redis.setex(key, ttl, JSON.stringify(data));
  for (const tag of tags) {
    await redis.sadd(`tag:${tag}`, key);
  }
}

async function invalidateByTag(tag) {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length) await redis.del(...keys);
  await redis.del(`tag:${tag}`);
}

// Usage
await cacheWithTags('product:123', ['product', 'category:electronics'], productData, 3600);
await invalidateByTag('category:electronics'); // Deletes all products in category

Memory Management: Eviction Policies

Redis memory is finite. Configure maxmemory and policy in redis.conf:

maxmemory 2gb
maxmemory-policy allkeys-lru  # Least Recently Used (best for caches)
# Alternatives: volatile-lru, allkeys-random, volatile-ttl, noeviction

Monitor memory with redis-cli INFO memory and redis-cli --stat.

Common Pitfalls

  • Hot keys: A single popular key (e.g., trending product) can overwhelm Redis. Replicate across shards or use client-side caching.
  • Big keys: Redis operations on keys > 10MB are slow. Break into hash structures or compress before storing.
  • Cache avalanche: Many keys expiring simultaneously. Add jitter to TTLs: ttl + Math.random() * 60.
  • No monitoring: Track hit/miss ratio. Aim for >80% hit rate for effective caching.

Conclusion

Redis caching transforms database-bound applications. Start with cache-aside for individual entities, add write-through for consistency, and implement stampede protection for high-traffic endpoints. Monitor hit ratios and adjust TTLs accordingly. With proper serialization and eviction policies, you can reduce database load by 90% and deliver sub-10ms responses at scale.

Comments

Join the conversation — sign in to leave a comment.