Skip to main content
← All Articles Β· Β· 13 min read

Redis Caching Patterns for Node.js: Cache-Aside, Write-Through, and TTL Strategies

Master Redis caching patterns in Node.js: cache-aside, write-through, write-behind, read-through, and TTL strategies. Code examples, when to use each pattern, and cache invalidation techniques.

rediscachingnodejsperformancebackenddatabasearchitecture

Bad caching is worse than no caching. It introduces stale data bugs, inconsistent state, and thundering herd problems that are notoriously hard to debug in production. Good caching can reduce database load by 90% and cut API response times from 200ms to under 10ms.

This guide covers the four core Redis caching patterns with real Node.js code β€” and more importantly, when to use each one.

Setting Up: Redis Client in Node.js

import { createClient } from 'redis';

const redis = createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379',
});

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

Use ioredis for advanced features (cluster, sentinel, Lua scripting). Use the official redis package (v4+) for most applications β€” it’s lighter and has better TypeScript support.

Pattern 1: Cache-Aside (Lazy Loading)

Cache-Aside is the most common pattern. The application checks the cache first; on a miss, it reads from the database and populates the cache.

async function getUserById(userId: string): Promise<User | null> {
  const cacheKey = `user:${userId}`;

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

  // 2. Cache miss β€” read from database
  const user = await db.users.findUnique({ where: { id: userId } });
  if (!user) return null;

  // 3. Populate cache with TTL
  await redis.setEx(cacheKey, 3600, JSON.stringify(user)); // 1 hour TTL
  return user;
}

When to use Cache-Aside:

  • Read-heavy workloads where database access is expensive
  • Data that changes infrequently (user profiles, product catalog)
  • When you can tolerate slight staleness (within TTL window)

Cache-Aside problems to watch for:

Cache stampede (thundering herd): When a popular cache entry expires, multiple concurrent requests all miss the cache simultaneously, all hit the database at once. Solution: use a mutex lock:

import { Mutex } from 'async-mutex';
const locks = new Map<string, Mutex>();

async function getUserByIdSafe(userId: string): Promise<User | null> {
  const cacheKey = `user:${userId}`;
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Acquire lock β€” only one request fetches from DB
  if (!locks.has(userId)) locks.set(userId, new Mutex());
  const mutex = locks.get(userId)!;

  return mutex.runExclusive(async () => {
    // Double-check after acquiring lock
    const rechecked = await redis.get(cacheKey);
    if (rechecked) return JSON.parse(rechecked);

    const user = await db.users.findUnique({ where: { id: userId } });
    if (user) await redis.setEx(cacheKey, 3600, JSON.stringify(user));
    return user;
  });
}

Use the Redis Cache Key Generator to establish consistent key naming conventions.

Pattern 2: Write-Through

Write-Through updates the cache synchronously whenever data is written to the database. The write is complete only when both the database and cache are updated.

async function updateUser(userId: string, data: Partial<User>): Promise<User> {
  // 1. Write to database
  const updated = await db.users.update({
    where: { id: userId },
    data,
  });

  // 2. Immediately update cache (synchronous write-through)
  const cacheKey = `user:${userId}`;
  await redis.setEx(cacheKey, 3600, JSON.stringify(updated));

  return updated;
}

When to use Write-Through:

  • Data that’s read far more often than written (user profiles, configuration)
  • When you can’t tolerate any cache staleness after writes
  • In combination with Cache-Aside for read path

Write-Through problems:

Write overhead: Every write pays the cost of both DB write + cache write. For write-heavy workloads, this doubles your write latency.

Cache pollution: You might cache data that’s never read. Solution: only write-through for entries that already exist in the cache:

async function updateUserConditional(userId: string, data: Partial<User>): Promise<User> {
  const updated = await db.users.update({ where: { id: userId }, data });

  // Only update cache if entry already exists (avoid cache pollution)
  const cacheKey = `user:${userId}`;
  const exists = await redis.exists(cacheKey);
  if (exists) {
    await redis.setEx(cacheKey, 3600, JSON.stringify(updated));
  }

  return updated;
}

Pattern 3: Write-Behind (Write-Back)

Write-Behind writes to the cache immediately but defers the database write to a background process. This dramatically reduces database write pressure for high-frequency updates.

// Write immediately to cache; queue for async DB flush
async function incrementUserMetric(userId: string, metric: string): Promise<void> {
  const cacheKey = `user_metrics:${userId}:${metric}`;
  const queueKey = 'metrics:dirty_queue';

  // 1. Increment in Redis immediately (fast, atomic)
  await redis.incr(cacheKey);

  // 2. Add to dirty queue for background flush (with deduplication)
  await redis.sAdd(queueKey, `${userId}:${metric}`);
}

// Background job: flush dirty metrics to database every 30 seconds
async function flushMetricsToDB(): Promise<void> {
  const queueKey = 'metrics:dirty_queue';
  const dirtyKeys = await redis.sMembers(queueKey);
  if (dirtyKeys.length === 0) return;

  // Remove from queue before processing (idempotent flush)
  await redis.del(queueKey);

  for (const key of dirtyKeys) {
    const [userId, metric] = key.split(':');
    const value = await redis.get(`user_metrics:${userId}:${metric}`);
    if (value !== null) {
      await db.userMetrics.upsert({
        where: { userId_metric: { userId, metric } },
        update: { value: parseInt(value) },
        create: { userId, metric, value: parseInt(value) },
      });
    }
  }
}

// Run every 30 seconds
setInterval(flushMetricsToDB, 30_000);

When to use Write-Behind:

  • High-frequency writes where exact real-time persistence isn’t required (view counters, analytics events, activity logs)
  • Rate limiting data (request counts per user)
  • Gaming scores, live dashboards

Write-Behind risks:

  • Data loss on Redis failure (accept up to 30s of metric loss)
  • Complexity: background flush errors need retry logic
  • Not suitable for financial data or anything requiring ACID guarantees

Pattern 4: Read-Through

Read-Through is Cache-Aside but the cache layer itself handles the database fallback. This is typically implemented via a caching library (like cacheable or custom middleware) rather than manually:

class ReadThroughCache<T> {
  constructor(
    private redis: ReturnType<typeof createClient>,
    private loader: (key: string) => Promise<T | null>,
    private ttl: number
  ) {}

  async get(key: string): Promise<T | null> {
    const cached = await this.redis.get(key);
    if (cached !== null) return JSON.parse(cached);

    const value = await this.loader(key);
    if (value !== null) {
      await this.redis.setEx(key, this.ttl, JSON.stringify(value));
    }
    return value;
  }

  async invalidate(key: string): Promise<void> {
    await this.redis.del(key);
  }
}

// Usage:
const userCache = new ReadThroughCache<User>(
  redis,
  (userId) => db.users.findUnique({ where: { id: userId } }),
  3600
);

const user = await userCache.get(`user:${userId}`);

TTL Strategy by Data Type

Choosing the right TTL prevents both stale data and excessive cache pressure:

Data TypeRecommended TTLReasoning
User sessions1-24hBalance security vs re-auth friction
Auth tokensMatch token expiryNever cache past token expiry
User profiles1hChanges infrequently; staleness acceptable
API responses (3rd party)5-60 minRespect upstream rate limits
Search results10-30 minPersonalized = shorter TTL
Rate limit countersMatch rate windowMust expire with window
Product catalog6-24hRarely changes; high read volume
Session cart30 min rollingReset TTL on each cart update
Static config1-6hReload on deploy

Rolling TTL pattern β€” reset TTL on every access (good for sessions):

async function getSession(sessionId: string): Promise<Session | null> {
  const key = `session:${sessionId}`;
  const [value] = await redis
    .multi()
    .get(key)
    .expire(key, 1800) // Reset to 30 min on every access
    .exec();
  return value ? JSON.parse(value as string) : null;
}

Cache Invalidation: The Hard Part

β€œThere are only two hard things in Computer Science: cache invalidation and naming things.” β€” Phil Karlton

Tag-based invalidation β€” group related cache keys:

async function invalidateUserCache(userId: string): Promise<void> {
  // Collect all user-related keys
  const keys = [
    `user:${userId}`,
    `user:${userId}:profile`,
    `user:${userId}:permissions`,
    `user:${userId}:preferences`,
  ];
  if (keys.length > 0) await redis.del(keys);
}

// Call on any user update
async function updateUserProfile(userId: string, data: Partial<User>) {
  const updated = await db.users.update({ where: { id: userId }, data });
  await invalidateUserCache(userId); // Invalidate entire user cache namespace
  return updated;
}

Event-driven invalidation β€” invalidate via database change events (PostgreSQL LISTEN/NOTIFY or change data capture):

// PostgreSQL LISTEN for user changes
const client = await pool.connect();
await client.query('LISTEN user_changes');
client.on('notification', async (msg) => {
  const { userId } = JSON.parse(msg.payload || '{}');
  await invalidateUserCache(userId);
});

Caching done right is one of the highest-leverage performance improvements in backend engineering. Start with Cache-Aside, move to Write-Through for write paths you own, and use Write-Behind only for truly high-frequency non-critical counters.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.