Redis Caching Strategies for Node.js APIs
Cache-aside, write-through, and pub/sub invalidation patterns using Redis with Node.js — reducing database load by up to 95% on read-heavy endpoints.
Overview
Redis is an in-memory data structure store used as a cache, session store, and pub/sub message broker. For read-heavy Node.js APIs, caching with Redis can reduce database queries by 80–95% and cut p99 latency from hundreds of milliseconds to single digits.
Setup
npm install ioredis
// lib/redis.ts
import Redis from "ioredis";
export const redis = new Redis({
host: process.env.REDIS_HOST ?? "localhost",
port: 6379,
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
redis.on("error", (err) => console.error("[Redis]", err));
Pattern 1: Cache-Aside (Lazy Loading)
The most common pattern. Try cache first; on miss, fetch from DB and populate cache.
// services/product.service.ts
import { redis } from "@/lib/redis";
import { db } from "@/lib/db";
const TTL = 60 * 5; // 5 minutes
export async function getProduct(id: string) {
const cacheKey = `product:${id}`;
// 1. Try cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss — hit DB
const product = await db.product.findUniqueOrThrow({ where: { id } });
// 3. Populate cache
await redis.setex(cacheKey, TTL, JSON.stringify(product));
return product;
}
Pattern 2: Write-Through
On every write, update both DB and cache atomically — cache is always fresh.
export async function updateProduct(id: string, data: Partial<Product>) {
// Write to DB first
const updated = await db.product.update({ where: { id }, data });
// Immediately update cache — no stale reads
await redis.setex(`product:${id}`, TTL, JSON.stringify(updated));
return updated;
}
Pattern 3: Cache Invalidation via Tags
Group related cache keys under a tag for bulk invalidation:
// Cache with tag tracking
async function cacheWithTag(key: string, tag: string, value: unknown, ttl: number) {
const pipeline = redis.pipeline();
pipeline.setex(key, ttl, JSON.stringify(value));
pipeline.sadd(`tag:${tag}`, key); // track key under tag
pipeline.expire(`tag:${tag}`, ttl + 60); // clean up tag set too
await pipeline.exec();
}
// Invalidate all keys under a tag
async function invalidateTag(tag: string) {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length) {
const pipeline = redis.pipeline();
keys.forEach((k) => pipeline.del(k));
pipeline.del(`tag:${tag}`);
await pipeline.exec();
}
}
// Usage
await cacheWithTag("products:list:page:1", "products", pageData, 300);
await invalidateTag("products"); // bust all product list pages after a write
Pattern 4: Request Coalescing (Thundering Herd Prevention)
Multiple concurrent cache misses for the same key will all hit the DB simultaneously. Prevent this with a lock:
import { Mutex } from "async-mutex";
const locks = new Map<string, Mutex>();
async function getWithCoalescing<T>(key: string, fetcher: () => Promise<T>, ttl: number): Promise<T> {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as T;
// Acquire per-key lock
if (!locks.has(key)) locks.set(key, new Mutex());
const release = await locks.get(key)!.acquire();
try {
// Re-check after acquiring lock (another request may have populated it)
const recheck = await redis.get(key);
if (recheck) return JSON.parse(recheck) as T;
const data = await fetcher();
await redis.setex(key, ttl, JSON.stringify(data));
return data;
} finally {
release();
locks.delete(key);
}
}
Pub/Sub for Real-Time Cache Invalidation
When you have multiple API instances, invalidate caches across all instances via pub/sub:
// publisher (after a write)
await redis.publish("cache:invalidate", JSON.stringify({ keys: ["product:123"] }));
// subscriber (in every instance)
const subscriber = redis.duplicate();
await subscriber.subscribe("cache:invalidate");
subscriber.on("message", async (_, message) => {
const { keys } = JSON.parse(message) as { keys: string[] };
if (keys.length) await redis.del(...keys);
});
Measuring Cache Effectiveness
// Middleware to track hit/miss ratio
export async function cacheMetrics(key: string, hit: boolean) {
const pipeline = redis.pipeline();
pipeline.incr(hit ? "cache:hits" : "cache:misses");
pipeline.expire("cache:hits", 86400);
pipeline.expire("cache:misses", 86400);
await pipeline.exec();
}
// Endpoint to check ratio
app.get("/metrics/cache", async (_, res) => {
const [hits, misses] = await redis.mget("cache:hits", "cache:misses");
const h = Number(hits ?? 0), m = Number(misses ?? 0);
res.json({ hits: h, misses: m, ratio: h / (h + m || 1) });
});
Key Takeaways
- Cache-aside for reads, write-through for consistency-critical data
- Tag-based invalidation is safer than wildcard
KEYS *scans in production - Use a distributed lock to prevent thundering herd on cache miss
- Pub/sub invalidation keeps all API instances in sync without sticky sessions
- Always set a TTL — never cache indefinitely