MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Redis Caching Strategies for Node.js Applications

Implement effective caching with Redis: cache-aside, write-through, TTL, and invalidation.

RedisCachingNode.jsPerformance

By MinhVo

Introduction

Caching is one of the most impactful performance optimizations you can apply to any Node.js application. Redis, an in-memory data store, serves as the de facto standard for application-level caching due to its sub-millisecond response times, versatile data structures, and robust persistence options. When properly implemented, Redis caching can reduce database load by 90% or more, cut API response times from hundreds of milliseconds to single digits, and dramatically improve the scalability ceiling of your application.

The challenge lies not in installing Redis—getting started takes minutes—but in choosing the right caching strategy for your specific access patterns, implementing proper invalidation, handling cache consistency, and avoiding common pitfalls like thundering herd problems or stale data serving. A poorly implemented cache can actually degrade performance and correctness, making your application slower and less reliable than having no cache at all.

This comprehensive guide covers the major caching strategies, their trade-offs, implementation patterns in Node.js, and production-grade techniques for cache management. Whether you're caching database queries, API responses, or computed results, the patterns here will help you build a caching layer that delivers consistent, measurable performance improvements.

Redis Caching Architecture

Understanding Redis Caching: Core Concepts

What Is Caching?

Caching stores frequently accessed data in a fast-access storage layer (like Redis in memory) so that future requests for the same data can be served without recomputing or re-fetching from the primary data source (like a database or external API). The fundamental trade-off is between data freshness and access speed.

Redis as a Cache

Redis is uniquely suited as a caching layer because:

  • Speed: In-memory storage delivers sub-millisecond read/write latency
  • Data structures: Strings, hashes, lists, sets, sorted sets, and streams enable sophisticated caching patterns
  • TTL support: Built-in time-to-live expiration eliminates stale data
  • Pub/Sub: Enables real-time cache invalidation across distributed instances
  • Persistence: Optional RDB snapshots and AOF logging prevent total cache loss on restart
  • Clustering: Redis Cluster and Sentinel provide horizontal scaling and high availability

Cache Hit vs. Cache Miss

A cache hit occurs when the requested data is found in the cache. A cache miss occurs when the data is absent, requiring a fetch from the primary source and a subsequent cache population. The hit rate (hits / (hits + misses)) is the primary metric for cache effectiveness. Production systems should target 85-95% hit rates for frequently accessed data.

import Redis from 'ioredis';
 
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  maxRetriesPerRequest: 3,
  retryStrategy: (times) => Math.min(times * 50, 2000),
});
 
// Basic cache operations
async function getOrSet<T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttlSeconds: number = 300
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) {
    return JSON.parse(cached);
  }
 
  const fresh = await fetchFn();
  await redis.setex(key, ttlSeconds, JSON.stringify(fresh));
  return fresh;
}

Caching Strategy Diagram

Architecture and Design Patterns

Cache-Aside (Lazy Loading)

Cache-aside is the most common caching pattern. The application checks the cache first; on a miss, it fetches from the database, populates the cache, and returns the data:

async function getUserById(userId: string): Promise<User> {
  const cacheKey = `user:${userId}`;
 
  // 1. Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
 
  // 2. Cache miss — fetch from database
  const user = await db.user.findUnique({ where: { id: userId } });
  if (!user) throw new Error('User not found');
 
  // 3. Populate cache with TTL
  await redis.setex(cacheKey, 600, JSON.stringify(user));
 
  return user;
}

Advantages: Only caches data that's actually requested. Simple to implement. Tolerant of cache failures (falls back to database).

Disadvantages: First request for any key always results in a cache miss (cold start). Requires the application to manage cache population logic.

Write-Through

Write-through caching writes data to both the cache and the database simultaneously when data is created or updated:

async function createUser(userData: CreateUserInput): Promise<User> {
  // 1. Write to database
  const user = await db.user.create({ data: userData });
 
  // 2. Write to cache simultaneously
  await redis.setex(`user:${user.id}`, 600, JSON.stringify(user));
 
  return user;
}
 
async function updateUser(userId: string, updates: Partial<User>): Promise<User> {
  // 1. Update database
  const user = await db.user.update({
    where: { id: userId },
    data: updates,
  });
 
  // 2. Update cache
  await redis.setex(`user:${user.id}`, 600, JSON.stringify(user));
 
  return user;
}

Advantages: Cache is always consistent with the database. No stale data on reads. Eliminates cold-start misses for known entities.

Disadvantages: Writes are slightly slower (two operations). Caches data that may never be read (write-heavy workloads waste memory).

Write-Behind (Write-Back)

Write-behind caching writes to the cache first, then asynchronously flushes to the database:

import { Queue } from 'bullmq';
 
const dbWriteQueue = new Queue('db-writes', {
  connection: { host: 'localhost', port: 6379 },
});
 
async function updateUserFast(userId: string, updates: Partial<User>): Promise<User> {
  // 1. Write to cache immediately
  const existing = JSON.parse(await redis.get(`user:${userId}`) || '{}');
  const updated = { ...existing, ...updates };
  await redis.setex(`user:${userId}`, 600, JSON.stringify(updated));
 
  // 2. Queue async database write
  await dbWriteQueue.add('update-user', { userId, updates });
 
  return updated;
}
 
// Worker processes database writes
const worker = new Worker('db-writes', async (job) => {
  const { userId, updates } = job.data;
  await db.user.update({ where: { id: userId }, data: updates });
}, { connection: { host: 'localhost', port: 6379 } });

Advantages: Fastest write path. Database write load can be batched and smoothed.

Disadvantages: Risk of data loss if Redis crashes before flushing. More complex architecture. Requires a background job system.

Read-Through

Read-through caching delegates cache population to the cache layer itself:

class ReadThroughCache<T> {
  constructor(
    private redis: Redis,
    private fetchFn: (key: string) => Promise<T>,
    private ttl: number
  ) {}
 
  async get(key: string): Promise<T> {
    const cached = await this.redis.get(key);
    if (cached) return JSON.parse(cached);
 
    const fresh = await this.fetchFn(key);
    await this.redis.setex(key, this.ttl, JSON.stringify(fresh));
    return fresh;
  }
}
 
// Usage
const userCache = new ReadThroughCache(
  redis,
  async (key) => {
    const userId = key.split(':')[1];
    return db.user.findUnique({ where: { id: userId } });
  },
  600
);
 
const user = await userCache.get('user:abc123');

Step-by-Step Implementation

Setting Up Redis in Node.js

// lib/redis.ts
import Redis from 'ioredis';
 
class RedisClient {
  private static instance: Redis | null = null;
 
  static getInstance(): Redis {
    if (!this.instance) {
      this.instance = new Redis({
        host: process.env.REDIS_HOST || 'localhost',
        port: parseInt(process.env.REDIS_PORT || '6379'),
        password: process.env.REDIS_PASSWORD,
        db: parseInt(process.env.REDIS_DB || '0'),
        maxRetriesPerRequest: 3,
        retryStrategy: (times) => {
          if (times > 3) return null; // Stop retrying
          return Math.min(times * 100, 3000);
        },
        lazyConnect: true,
      });
 
      this.instance.on('error', (err) => {
        console.error('Redis connection error:', err.message);
      });
 
      this.instance.on('connect', () => {
        console.log('Redis connected');
      });
    }
    return this.instance;
  }
}
 
export const redis = RedisClient.getInstance();

Implementing a Cache Manager

// lib/cache-manager.ts
import { redis } from './redis';
import { createHash } from 'crypto';
 
interface CacheOptions {
  ttl?: number;
  prefix?: string;
  staleWhileRevalidate?: boolean;
}
 
export class CacheManager {
  private prefix: string;
  private defaultTTL: number;
 
  constructor(prefix: string = 'app', defaultTTL: number = 300) {
    this.prefix = prefix;
    this.defaultTTL = defaultTTL;
  }
 
  private buildKey(key: string): string {
    return `${this.prefix}:${key}`;
  }
 
  async get<T>(key: string): Promise<T | null> {
    const data = await redis.get(this.buildKey(key));
    return data ? JSON.parse(data) : null;
  }
 
  async set<T>(key: string, value: T, ttl?: number): Promise<void> {
    const serialized = JSON.stringify(value);
    const finalTTL = ttl ?? this.defaultTTL;
    await redis.setex(this.buildKey(key), finalTTL, serialized);
  }
 
  async invalidate(key: string): Promise<void> {
    await redis.del(this.buildKey(key));
  }
 
  async invalidatePattern(pattern: string): Promise<void> {
    const keys = await redis.keys(`${this.prefix}:${pattern}`);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }
 
  hashKey(input: string): string {
    return createHash('md5').update(input).digest('hex');
  }
}
 
export const cache = new CacheManager('myapp', 300);

Express.js Middleware Caching

// middleware/cache.ts
import { Request, Response, NextFunction } from 'express';
import { redis } from '../lib/redis';
 
export function cacheMiddleware(ttl: number = 60) {
  return async (req: Request, res: Response, next: NextFunction) => {
    const key = `route:${req.method}:${req.originalUrl}`;
 
    const cached = await redis.get(key);
    if (cached) {
      return res.json(JSON.parse(cached));
    }
 
    // Monkey-patch res.json to intercept and cache
    const originalJson = res.json.bind(res);
    res.json = (body: any) => {
      redis.setex(key, ttl, JSON.stringify(body));
      return originalJson(body);
    };
 
    next();
  };
}
 
// Usage
app.get('/api/products', cacheMiddleware(120), async (req, res) => {
  const products = await db.product.findMany();
  res.json(products);
});

Implementation Workflow

Real-World Use Cases

Use Case 1: Session Storage

Redis excels as a session store due to its speed and TTL support:

import session from 'express-session';
import RedisStore from 'connect-redis';
 
const redisStore = new RedisStore({
  client: redis,
  prefix: 'sess:',
  ttl: 86400, // 24 hours
});
 
app.use(session({
  store: redisStore,
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000,
  },
}));

Use Case 2: API Response Caching

Cache expensive API responses to reduce downstream service load:

async function getWeatherData(city: string): Promise<WeatherData> {
  const cacheKey = `weather:${city.toLowerCase()}`;
 
  return cache.getOrSet(cacheKey, async () => {
    const response = await fetch(
      `https://api.weather.com/v1/current?city=${city}`
    );
    return response.json();
  }, 900); // Cache for 15 minutes
}

Use Case 3: Query Result Caching

Cache database query results to reduce database load:

async function getProductsByCategory(
  categoryId: string,
  page: number = 1,
  limit: number = 20
): Promise<Product[]> {
  const cacheKey = `products:cat:${categoryId}:p${page}:l${limit}`;
 
  return cache.getOrSet(cacheKey, async () => {
    return db.product.findMany({
      where: { categoryId, published: true },
      skip: (page - 1) * limit,
      take: limit,
      orderBy: { createdAt: 'desc' },
      include: { category: true },
    });
  }, 180); // Cache for 3 minutes
}

Use Case 4: Rate Limiting

Use Redis for distributed rate limiting:

async function rateLimit(
  identifier: string,
  maxRequests: number = 100,
  windowSeconds: number = 60
): Promise<{ allowed: boolean; remaining: number }> {
  const key = `ratelimit:${identifier}`;
  const current = await redis.incr(key);
 
  if (current === 1) {
    await redis.expire(key, windowSeconds);
  }
 
  return {
    allowed: current <= maxRequests,
    remaining: Math.max(0, maxRequests - current),
  };
}

Best Practices for Production

  1. Always set TTLs: Never store cache entries without expiration. Unbounded cache growth leads to memory exhaustion and increasingly stale data. Use setex() instead of set().

  2. Use cache key namespacing: Prefix keys with the resource type and version (user:v2:${id}) to enable bulk invalidation and prevent key collisions between services.

  3. Implement circuit breakers: If Redis becomes unavailable, your application should gracefully degrade to direct database access rather than throwing errors. Use try/catch around cache operations.

  4. Monitor cache hit rates: Track hit/miss ratios per key namespace. A hit rate below 80% suggests cache keys are too specific or TTLs are too short. Above 95% may indicate over-caching (data rarely refreshed).

  5. Avoid caching large objects: Redis stores everything in memory. Caching 10MB JSON blobs wastes expensive RAM. Cache only the data you need, or compress it.

  6. Use Redis pipelines for batch operations: When performing multiple cache operations, pipeline them to reduce network round-trips:

const pipeline = redis.pipeline();
users.forEach(user => {
  pipeline.setex(`user:${user.id}`, 600, JSON.stringify(user));
});
await pipeline.exec();
  1. Implement stale-while-revalidate: Serve stale cached data immediately while triggering an async background refresh. This eliminates cache-miss latency for end users.

  2. Use connection pooling: Node.js is single-threaded, but Redis connections can still benefit from pooling in multi-instance deployments. Use ioredis cluster mode for distributed setups.

Common Pitfalls and Solutions

PitfallImpactSolution
No TTL on cache entriesMemory exhaustion; permanently stale dataAlways use setex() with appropriate TTLs
Thundering herd on cache missDatabase overload when popular key expiresUse lock/mutex to prevent concurrent refetches
Cache stampede during cold startAll requests miss simultaneouslyPre-warm cache on application boot
Inconsistent invalidationStale data served after updatesUse write-through or targeted invalidation patterns
Caching non-serializable dataSilent data loss or corruptionEnsure all cached values are JSON-serializable
No fallback on Redis failureComplete application failureWrap cache ops in try/catch; degrade to DB on error

Performance Optimization

Thundering Herd Prevention

When a popular cache key expires, hundreds of concurrent requests may all miss the cache simultaneously, overwhelming the database. Use a distributed lock to ensure only one request refetches:

async function getWithLock<T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttl: number
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
 
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, '1', 'EX', 10, 'NX');
 
  if (acquired) {
    try {
      const fresh = await fetchFn();
      await redis.setex(key, ttl, JSON.stringify(fresh));
      return fresh;
    } finally {
      await redis.del(lockKey);
    }
  }
 
  // Another process is fetching — wait and retry
  await new Promise(resolve => setTimeout(resolve, 50));
  return getWithLock(key, fetchFn, ttl);
}

Cache Warming

Pre-populate the cache on application boot to avoid cold-start misses:

async function warmCache(): Promise<void> {
  const popularProducts = await db.product.findMany({
    where: { published: true },
    orderBy: { viewCount: 'desc' },
    take: 100,
  });
 
  const pipeline = redis.pipeline();
  for (const product of popularProducts) {
    pipeline.setex(`product:${product.id}`, 600, JSON.stringify(product));
  }
  await pipeline.exec();
  console.log(`Cache warmed with ${popularProducts.length} products`);
}

Cache Invalidation Patterns

// Pattern-based invalidation
async function invalidateUserCache(userId: string): Promise<void> {
  const patterns = [
    `user:${userId}`,
    `user:${userId}:*`,
    `posts:user:${userId}:*`,
    `notifications:${userId}`,
  ];
 
  for (const pattern of patterns) {
    const keys = await redis.keys(pattern);
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }
}

Comparison with Alternatives

FeatureRedisMemcachedIn-Memory (Map)CDN Cache
SpeedSub-msSub-msSub-ms10-50ms
PersistenceOptional (RDB/AOF)NoneNoneEdge TTL
Data structuresRich (strings, hashes, lists, sets)Strings onlyAnyHTTP responses
DistributedBuilt-in clusteringBuilt-inSingle processGlobal edge
TTL supportNativeNativeManualHeader-based
Pub/SubBuilt-inNoneNoneNone
Memory efficiencyGoodGoodPoor at scaleN/A

Advanced Patterns

Multi-Layer Caching

Combine in-memory and Redis caching for maximum performance:

import LRU from 'lru-cache';
 
const localCache = new LRU<string, any>({
  max: 1000,
  ttl: 30_000, // 30 seconds local
});
 
async function getWithLocalCache<T>(key: string, fetchFn: () => Promise<T>): Promise<T> {
  // Layer 1: Local memory (fastest, per-process)
  const local = localCache.get(key);
  if (local) return local;
 
  // Layer 2: Redis (shared across processes)
  const redisData = await redis.get(key);
  if (redisData) {
    const parsed = JSON.parse(redisData);
    localCache.set(key, parsed);
    return parsed;
  }
 
  // Layer 3: Database (slowest)
  const fresh = await fetchFn();
  await redis.setex(key, 300, JSON.stringify(fresh));
  localCache.set(key, fresh);
  return fresh;
}

Cache Tags

Associate cache entries with tags for targeted invalidation:

async function setWithTags(
  key: string,
  value: any,
  tags: string[],
  ttl: number
): Promise<void> {
  const pipeline = redis.pipeline();
  pipeline.setex(key, ttl, JSON.stringify(value));
 
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, key);
    pipeline.expire(`tag:${tag}`, ttl);
  }
 
  await pipeline.exec();
}
 
async function invalidateByTag(tag: string): Promise<void> {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    const pipeline = redis.pipeline();
    keys.forEach(key => pipeline.del(key));
    pipeline.del(`tag:${tag}`);
    await pipeline.exec();
  }
}

Testing Strategies

import Redis from 'ioredis-mock';
import { CacheManager } from './cache-manager';
 
describe('CacheManager', () => {
  let cache: CacheManager;
  let mockRedis: Redis;
 
  beforeEach(() => {
    mockRedis = new Redis();
    cache = new CacheManager('test', 60);
    // Inject mock redis
    (cache as any).redis = mockRedis;
  });
 
  test('get returns null on cache miss', async () => {
    const result = await cache.get('nonexistent');
    expect(result).toBeNull();
  });
 
  test('set and get round-trip', async () => {
    await cache.set('key1', { name: 'test' });
    const result = await cache.get('key1');
    expect(result).toEqual({ name: 'test' });
  });
 
  test('invalidate removes key', async () => {
    await cache.set('key1', 'value1');
    await cache.invalidate('key1');
    const result = await cache.get('key1');
    expect(result).toBeNull();
  });
 
  test('keys expire after TTL', async () => {
    await cache.set('ttl-key', 'value', 1);
    // ioredis-mock doesn't auto-expire, but we verify setex was called
    const ttl = await mockRedis.ttl('test:ttl-key');
    expect(ttl).toBeGreaterThan(0);
  });
});

Future Outlook

Redis continues to evolve with features like Redis Stack (JSON, Search, TimeSeries, Bloom filters), Redis Functions (server-side scripting), and improved cluster management. For Node.js applications, the future of caching lies in intelligent, application-aware caching strategies:

  • Edge caching with Cloudflare Workers or Vercel Edge: Moving cache closer to users at the CDN edge
  • Application-level cache intelligence: Frameworks like Next.js with built-in caching and revalidation
  • Cache warming with ML: Predictive cache population based on usage patterns
  • Unified caching layers: Solutions like Prisma Accelerate that abstract caching from the application layer

Conclusion

Redis caching is a powerful tool for Node.js performance optimization, but its effectiveness depends entirely on choosing the right strategy and implementing it correctly. The cache-aside pattern offers the best balance of simplicity and safety for most applications, while write-through caching suits use cases requiring strong consistency.

Key takeaways:

  1. Choose cache-aside for read-heavy workloads; write-through for consistency-critical data
  2. Always set TTLs to prevent memory exhaustion and stale data
  3. Implement thundering herd protection for high-traffic cache keys
  4. Monitor hit rates and adjust TTLs and key strategies based on actual usage patterns
  5. Build circuit breakers so cache failures degrade gracefully rather than crash the application

Start with a simple cache-aside implementation, measure the impact, and iterate. The performance gains from well-implemented Redis caching are often dramatic—frequently reducing database load by 90% and response times by 10x.