Caching Basics
8 examples to get you started with caching for Node.js APIs - 6 basic and 2 intermediate.
Prerequisites
npm install ioredis
npm install -D typescript@5.6 tsx @types/nodeRun Redis locally: docker run -d -p 6379:6379 redis:7. See ioredis for connection details.
Basic Examples
1. Cache-Aside Read
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
export async function getProduct(sku: string, loadFromDb: (sku: string) => Promise<Product | null>) {
const key = `product:${sku}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as Product;
const product = await loadFromDb(sku);
if (product) {
await redis.set(key, JSON.stringify(product), "EX", 300);
}
return product;
}- App owns consistency - DB is source of truth
- TTL (
EX 300) bounds staleness - On cache miss, only one code path loads DB
Related: ioredis - client setup
2. TTL Strategy
| Data type | Typical TTL | Invalidation |
|---|---|---|
| Product catalog | 5-15 min | On admin update |
| User session | 24h sliding | Logout deletes key |
| Config flags | 30-60s | Pub/sub or short TTL |
| Rate limit counters | 1 min window | Automatic expiry |
- Shorter TTL is simpler than perfect invalidation for low-risk reads
- Document max staleness per endpoint in API docs
3. Cache Delete on Write
export async function updateProduct(sku: string, data: Partial<Product>, save: () => Promise<Product>) {
const updated = await save();
await redis.del(`product:${sku}`);
return updated;
}- Write-through DB first, then invalidate cache
- For related keys, delete pattern or maintain reverse index set
- Missing invalidation causes stale reads until TTL expires
4. Namespaced Keys
const NS = `api:${process.env.APP_ENV}:v1`;
function productKey(sku: string) {
return `${NS}:product:${sku}`;
}- Prefix by environment so staging never hits prod keys
- Bump
v1tov2on breaking cache shape changes - Avoid unbounded key cardinality (no raw user search strings)
5. Graceful Degradation on Redis Down
export async function getWithFallback<T>(
fetchCache: () => Promise<T | null>,
fetchOrigin: () => Promise<T>
): Promise<T> {
try {
const hit = await fetchCache();
if (hit !== null) return hit;
} catch (err) {
console.warn({ msg: "cache_unavailable", err: (err as Error).message });
}
return fetchOrigin();
}- Redis outage should not take down read endpoints
- Log cache errors; alert on sustained failure
- Writes that depend on Redis (locks, rate limits) may still fail closed
6. JSON Serialization Cost
await redis.set(key, JSON.stringify(value), "EX", ttl);
const raw = await redis.get(key);
const value = raw ? (JSON.parse(raw) as T) : null;- Large objects hurt CPU and bandwidth - cache DTOs not full ORM graphs
- Consider MessagePack only when profiling proves JSON is hot
- See JSON Serialization Cost
Intermediate Examples
7. Stampede Prevention (Single-Flight Lock)
async function getOrLoad(key: string, ttlSec: number, loader: () => Promise<string>) {
const hit = await redis.get(key);
if (hit) return hit;
const lockKey = `${key}:lock`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
await new Promise((r) => setTimeout(r, 50));
return (await redis.get(key)) ?? loader();
}
try {
const value = await loader();
await redis.set(key, value, "EX", ttlSec);
return value;
} finally {
await redis.del(lockKey);
}
}- When hot key expires, one loader repopulates; others wait or retry get
- Lock TTL prevents deadlocks if loader crashes
- Alternative: probabilistic early expiration
Related: Distributed Locks - lock caveats
8. Cache-Aside vs Read-Through
| Pattern | Who loads on miss? | Node pattern |
|---|---|---|
| Cache-aside | Application code | Most Express/Fastify handlers |
| Read-through | Cache library | Less common in Node |
| Write-through | Sync write to cache + DB | Higher consistency cost |
- Node APIs almost always implement cache-aside explicitly
- Queue workers warm caches after bulk DB updates
FAQs
Should I cache POST responses?
Generally no. Cache GET reads with clear key semantics. Mutations invalidate.
Redis or in-memory LRU?
In-memory per instance causes inconsistent hits across replicas. Redis for shared cache.
What hit ratio is good?
70%+ on targeted hot keys is healthy. Low ratio means wrong keys or TTL too short.
Cache null results?
Yes with short TTL (30-60s) to protect DB from repeat misses for absent keys.
HTTP Cache-Control vs Redis?
CDN/Cache-Control for public static reads. Redis for personalized or auth-gated data.
Eviction policy?
allkeys-lru is common on dedicated cache nodes. Monitor memory usage alerts.
Multi-tenant key design?
Include tenantId in key prefix. Never cross-tenant cache bleed.
Does NestJS have built-in cache?
@nestjs/cache-manager wraps stores including Redis. Same TTL and invalidation rules apply.
Related
- ioredis - connection and pipelines
- Session Stores - Redis sessions
- Distributed Locks - coordination
- Caching Best Practices - team checklist
- Performance Basics - latency budgets
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.