Skip to main content
← All Articles ยท ยท 10 min read

Redis Caching Strategies for Node.js Applications

Master Redis caching in Node.js. Learn Cache-Aside, Write-Through, and Write-Back patterns with practical code examples, TTL strategies, and cache invalidation techniques.

redisnodejscachingbackendperformance

Redis is the industry-standard caching layer for Node.js applications โ€” but โ€œadd Redisโ€ is where most tutorials stop. Knowing how to use Redis matters as much as using it at all.

This guide covers the caching patterns that actually ship in production, with real Node.js code.


Why Cache?

Before patterns: a simple example. A product page queries the database on every load:

User Request โ†’ Node.js โ†’ PostgreSQL (8ms) โ†’ Response

At 100 req/s, thatโ€™s 100 database queries/second. At 1000 req/s, youโ€™re looking at connection pooling limits, query queue buildup, and degraded response times.

With Redis:

User Request โ†’ Node.js โ†’ Redis HIT (0.3ms) โ†’ Response
                              โ†“ MISS
                         PostgreSQL (8ms) โ†’ Redis (write) โ†’ Response

Cache hit rate of 90% means 90% of requests never touch the database.


Setup

npm install ioredis
// lib/redis.ts
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  // Reconnect on disconnect
  retryStrategy: (times) => Math.min(times * 50, 2000),
  // Lazy connect โ€” don't crash on startup if Redis is down
  lazyConnect: true,
});

redis.on('error', (err) => {
  console.error('Redis error:', err);
});

export default redis;

ioredis vs node-redis: Both are solid in 2026. ioredis has slightly nicer TypeScript types and built-in cluster support. node-redis (v4+) is the official Redis client and uses Promises natively. Use either.


Pattern 1: Cache-Aside (Lazy Loading)

The most common pattern. Your application checks the cache first; if itโ€™s a miss, fetch from the database and populate the cache.

// services/productService.ts
import redis from '../lib/redis';
import { db } from '../lib/db';

const PRODUCT_TTL = 60 * 5; // 5 minutes

export async function getProduct(id: string) {
  const cacheKey = `product:${id}`;

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

  // 2. Cache miss โ€” query database
  const product = await db.query(
    'SELECT * FROM products WHERE id = $1',
    [id]
  );

  if (!product) return null;

  // 3. Populate cache with TTL
  await redis.setex(cacheKey, PRODUCT_TTL, JSON.stringify(product));

  return product;
}

Pros: Simple, only caches data thatโ€™s actually requested (โ€œhotโ€ data). Cons: Cache miss always hits the database (cold start problem). Race condition on concurrent misses (thundering herd โ€” covered below).


Pattern 2: Write-Through

When you update the database, you update the cache simultaneously.

export async function updateProduct(id: string, data: Partial<Product>) {
  // 1. Update database
  const updated = await db.query(
    'UPDATE products SET name=$1, price=$2, updated_at=NOW() WHERE id=$3 RETURNING *',
    [data.name, data.price, id]
  );

  // 2. Update cache (write-through)
  const cacheKey = `product:${id}`;
  await redis.setex(cacheKey, PRODUCT_TTL, JSON.stringify(updated));

  return updated;
}

Pros: Cache is always fresh after writes. No stale reads. Cons: Every write touches both database and Redis. Higher write latency. Cache may hold data thatโ€™s rarely read (cache pollution).

When to use: Write-through makes sense when you need read-after-write consistency (e.g., user profile updates that should be immediately visible).


Pattern 3: Write-Behind (Write-Back)

Write to cache immediately, then asynchronously persist to the database. Very aggressive performance optimization.

import { Queue } from 'bullmq'; // or any queue system

const writeQueue = new Queue('db-writes', { connection: redis });

export async function updateUserActivity(userId: string, activity: object) {
  const cacheKey = `user:activity:${userId}`;

  // 1. Write to Redis immediately (fast)
  await redis.setex(cacheKey, 3600, JSON.stringify(activity));

  // 2. Queue async DB write
  await writeQueue.add('persist-activity', { userId, activity });

  return activity;
}

// Worker processes the queue
// worker.ts
const worker = new Worker('db-writes', async (job) => {
  if (job.name === 'persist-activity') {
    await db.query(
      'INSERT INTO user_activity (user_id, data, created_at) VALUES ($1, $2, NOW())',
      [job.data.userId, JSON.stringify(job.data.activity)]
    );
  }
});

Pros: Extremely low write latency. Ideal for high-write, lower-consistency workloads (analytics events, view counts, real-time feeds). Cons: Risk of data loss if Redis goes down before flush. Complexity of queue management. Not suitable for financial transactions.


Pattern 4: Cache Stampede Prevention

When a popular cached item expires, many requests hit the database simultaneously. This is the โ€œthundering herd problem.โ€

Solution A: Mutex Lock (Per-Key Locking)

export async function getProductWithLock(id: string) {
  const cacheKey = `product:${id}`;
  const lockKey = `lock:product:${id}`;

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

  // Try to acquire lock (SET NX EX = atomic lock)
  const lockAcquired = await redis.set(lockKey, '1', 'EX', 5, 'NX');

  if (lockAcquired) {
    try {
      // We have the lock โ€” fetch from DB
      const product = await db.query('SELECT * FROM products WHERE id = $1', [id]);
      await redis.setex(cacheKey, PRODUCT_TTL, JSON.stringify(product));
      return product;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // Another request is fetching โ€” wait and retry
    await new Promise(resolve => setTimeout(resolve, 100));
    const retried = await redis.get(cacheKey);
    return retried ? JSON.parse(retried) : null;
  }
}

Solution B: Probabilistic Early Expiration

Allow cache refresh to happen slightly before the TTL expires, so one request โ€œwinsโ€ the refresh:

interface CachedValue<T> {
  value: T;
  expiresAt: number;
  delta: number; // time to recompute
}

export async function getWithEarlyRefresh<T>(
  key: string,
  fetchFn: () => Promise<T>,
  ttl: number
): Promise<T> {
  const cached = await redis.get(key);

  if (cached) {
    const { value, expiresAt, delta }: CachedValue<T> = JSON.parse(cached);
    const now = Date.now() / 1000;
    const beta = 1; // tuning factor

    // Probabilistic check: refresh early if close to expiry
    if (now - delta * beta * Math.log(Math.random()) < expiresAt) {
      return value;
    }
  }

  // Fetch and cache
  const start = Date.now();
  const value = await fetchFn();
  const delta = (Date.now() - start) / 1000;

  const payload: CachedValue<T> = {
    value,
    expiresAt: Date.now() / 1000 + ttl,
    delta,
  };

  await redis.setex(key, ttl, JSON.stringify(payload));
  return value;
}

Cache Invalidation Strategies

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

Strategy 1: TTL-Based Expiry

Set a reasonable TTL and accept eventual consistency. Right for most read-heavy data:

// Product catalog: 5min TTL (can tolerate slight staleness)
await redis.setex('product:123', 300, JSON.stringify(product));

// User session: 24hr TTL
await redis.setex(`session:${token}`, 86400, JSON.stringify(session));

// Real-time stock: 10sec TTL
await redis.setex('stock:AAPL', 10, JSON.stringify(quote));

Strategy 2: Active Invalidation on Write

Delete or update the cache entry when the source changes:

// After updating a product, invalidate its cache
export async function updateProductAndInvalidate(id: string, data: object) {
  await db.query('UPDATE products SET ...', [id]);

  // Delete all related cache keys
  await redis.del(`product:${id}`);
  await redis.del(`product:detail:${id}`);
  // Also invalidate list caches that might include this product
  await redis.del('products:list:featured');
}

Strategy 3: Tag-Based Invalidation

Use sets to group related cache keys:

// When caching, register the key under relevant tags
async function cacheWithTags(key: string, value: unknown, ttl: number, tags: string[]) {
  const multi = redis.multi();
  multi.setex(key, ttl, JSON.stringify(value));

  for (const tag of tags) {
    multi.sadd(`tag:${tag}`, key);
    multi.expire(`tag:${tag}`, ttl + 60);
  }

  await multi.exec();
}

// Invalidate all keys tagged with a category
async function invalidateByTag(tag: string) {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length > 0) {
    await redis.del(...keys);
  }
  await redis.del(`tag:${tag}`);
}

// Usage
await cacheWithTags(`product:${id}`, product, 300, [`category:${product.categoryId}`, 'products']);
await invalidateByTag(`category:${categoryId}`); // invalidates all products in this category

Session Caching

Redis is the standard session store for Node.js with Express:

import session from 'express-session';
import { RedisStore } from 'connect-redis';

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000, // 24 hours
  },
}));

Rate Limiting with Redis

A practical bonus use case:

export async function checkRateLimit(
  identifier: string,
  limit: number,
  windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowMs = windowSeconds * 1000;

  // Use sorted set with sliding window
  const multi = redis.multi();
  multi.zremrangebyscore(key, 0, now - windowMs);
  multi.zadd(key, now, now.toString());
  multi.zcard(key);
  multi.expire(key, windowSeconds);
  const results = await multi.exec();

  const count = results?.[2]?.[1] as number ?? 0;
  const allowed = count <= limit;

  return {
    allowed,
    remaining: Math.max(0, limit - count),
    resetIn: windowSeconds,
  };
}

Production Tips

  1. Use key namespacing: Always prefix keys by environment and service (prod:users:123, dev:sessions:abc)

  2. Serialize carefully: JSON.parse/JSON.stringify are fine for most cases but consider MessagePack for high-throughput serialization

  3. Monitor memory usage: Set maxmemory and a sensible eviction policy (allkeys-lru for general caching)

  4. Handle Redis downtime gracefully:

async function safeRedisGet(key: string): Promise<string | null> {
  try {
    return await redis.get(key);
  } catch (err) {
    console.error('Redis unavailable, falling through to DB:', err);
    return null; // Cache miss โ€” let the request hit the DB
  }
}
  1. Pipeline bulk operations: Use redis.pipeline() to batch multiple commands and reduce round-trips

FAQ

Q: When should I NOT use caching? Donโ€™t cache data that must be real-time accurate (financial balances, inventory in e-commerce checkout), or data thatโ€™s queried randomly with no hot keys (youโ€™d just thrash the cache).

Q: Redis vs Memcached? Choose Redis. In 2026, there is almost no reason to use Memcached. Redis offers persistence, data structures, Lua scripting, clustering, and pub/sub โ€” Memcached is just a key-value cache. Both are fast; Redis is more capable.

Q: How do I handle Redis clustering? ioredis supports Redis Cluster natively. Managed Redis services (ElastiCache, Redis Cloud, Upstash) handle clustering for you. Use cluster mode when a single Redis instance canโ€™t hold your dataset in memory.

Q: What TTL should I use? Thereโ€™s no universal answer. General guidance: session data 1-24hr, user profiles 5-15min, product catalog 1-5min, homepage content 30s-2min, real-time data 5-30s. Start conservative (shorter TTL) and increase based on cache hit rates in production.

Q: How do I debug cache misses? Use Redis MONITOR (in dev only โ€” high overhead) or instrument your code with metrics. Key metrics: hit rate (target >80%), memory usage, eviction rate, and p99 get latency.

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.