Skip to content

Caching

Cache levels, eviction policies, cache stampede, cache invalidation — and why caching is the most over-applied and under-thought optimization.

Real-World Analogy

A chef’s mise en place: ingredients for tonight’s dishes are prepped and placed within arm’s reach (cache). The pantry (database) has everything, but fetching from it mid-service takes time. The mise en place is fast but limited — it only holds what was prepped, goes stale over time, and needs to be refreshed when the menu changes.

Cache Levels

From fastest to slowest:

L1/L2/L3 CPU cache     ~1-10ns   — managed by CPU
Process memory          ~100ns    — your in-process Map/LRU
Redis/Memcached         ~0.5ms    — network hop to cache server
Database                ~5-50ms   — disk or buffer cache
CDN edge                ~10-50ms  — geographically close edge node

Pick the right level for the data’s characteristics:

  • In-process: fastest, no network, but lost on restart, not shared across instances
  • Redis: shared across all instances, survives restart, slightly slower
  • CDN: for public static content — offloads origin entirely

In-Process LRU Cache

import LRU from 'lru-cache';

const userCache = new LRU<string, User>({
	max: 1000, // max 1000 entries
	ttl: 5 * 60 * 1000, // 5 minute TTL
	updateAgeOnGet: false // TTL doesn't reset on read
});

async function getUser(userId: string): Promise<User> {
	const cached = userCache.get(userId);
	if (cached) return cached;

	const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
	if (user) userCache.set(userId, user.rows[0]);
	return user.rows[0];
}

When to use: reference data that changes rarely (user roles, feature flags, config). Data that’s expensive to fetch but small enough to fit in memory.

When NOT to use: data that changes frequently, data where stale reads are unacceptable, data shared between requests in a stateless service (will be inconsistent across instances).

Redis Caching

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

async function getCachedOrder(orderId: string): Promise<Order | null> {
	const cached = await redis.get(`order:${orderId}`);
	if (cached) return JSON.parse(cached);

	const order = await db.findOrder(orderId);
	if (order) {
		await redis.setEx(
			`order:${orderId}`,
			300, // 5 minute TTL
			JSON.stringify(order)
		);
	}
	return order;
}

// Invalidate on update
async function updateOrder(orderId: string, data: Partial<Order>): Promise<Order> {
	const order = await db.updateOrder(orderId, data);
	await redis.del(`order:${orderId}`); // invalidate cache
	return order;
}

Cache warming: pre-populate cache before traffic hits:

async function warmCache() {
	const topProducts = await db.query('SELECT * FROM products ORDER BY view_count DESC LIMIT 1000');

	await Promise.all(
		topProducts.rows.map((product) =>
			redis.setEx(`product:${product.id}`, 3600, JSON.stringify(product))
		)
	);
}

Cache Stampede (Thundering Herd)

Cache expires. 1000 concurrent requests all see a cache miss and all hit the database simultaneously.

Time T:   cache expires
T+0ms:    request 1 sees miss, starts DB query
T+0ms:    request 2 sees miss, starts DB query
T+0ms:    request 1000 sees miss, starts DB query
T+50ms:   1000 DB queries complete, all populate cache

1000 DB queries instead of 1.

Fix 1: Mutex (Single Flight)

Only one request fetches, others wait:

import pLimit from 'p-limit';

const fetchLocks = new Map<string, Promise<any>>();

async function getWithLock<T>(key: string, fetcher: () => Promise<T>, ttl: number): Promise<T> {
	const cached = await redis.get(key);
	if (cached) return JSON.parse(cached);

	// If another request is already fetching, wait for it
	if (fetchLocks.has(key)) {
		await fetchLocks.get(key);
		const result = await redis.get(key);
		return result ? JSON.parse(result) : fetcher();
	}

	// I'm the one fetching
	const fetchPromise = fetcher().then(async (data) => {
		await redis.setEx(key, ttl, JSON.stringify(data));
		fetchLocks.delete(key);
		return data;
	});

	fetchLocks.set(key, fetchPromise);
	return fetchPromise;
}

Fix 2: Probabilistic Early Expiration

Randomly refresh the cache before it expires, based on remaining TTL:

async function getWithEarlyExpiry<T>(
	key: string,
	fetcher: () => Promise<T>,
	ttl: number
): Promise<T> {
	const cached = await redis.get(key);
	const ttlRemaining = await redis.ttl(key);

	if (cached) {
		// Probabilistically refresh when less than 20% TTL remains
		const shouldEarlyRefresh = ttlRemaining < ttl * 0.2 && Math.random() < 0.1;

		if (!shouldEarlyRefresh) return JSON.parse(cached);
		// Fall through to refresh (current user sees cached value)
		fetcher().then((data) => redis.setEx(key, ttl, JSON.stringify(data)));
		return JSON.parse(cached);
	}

	const data = await fetcher();
	await redis.setEx(key, ttl, JSON.stringify(data));
	return data;
}

Fix 3: Redis Lock

async function getWithRedisLock<T>(
	key: string,
	fetcher: () => 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', { NX: true, EX: 10 });

	if (!acquired) {
		// Someone else is fetching — wait and retry
		await sleep(100);
		const retried = await redis.get(key);
		return retried ? JSON.parse(retried) : getWithRedisLock(key, fetcher, ttl);
	}

	try {
		const data = await fetcher();
		await redis.setEx(key, ttl, JSON.stringify(data));
		return data;
	} finally {
		await redis.del(lockKey);
	}
}

Cache Invalidation

“There are only two hard things in computer science: cache invalidation and naming things.”

Time-based (TTL): simplest. Stale for up to TTL seconds. Fine for most cases.

Event-based: invalidate on write. No staleness, but requires coordinating cache with every write path.

// Pattern: write-through cache
async function updateProduct(productId: string, data: Partial<Product>): Promise<Product> {
	const product = await db.updateProduct(productId, data);

	// Update cache immediately (write-through)
	await redis.setEx(`product:${productId}`, 3600, JSON.stringify(product));

	// Also invalidate any list caches that include this product
	await redis.del('products:featured');
	await redis.del(`products:category:${product.categoryId}`);

	return product;
}

Tag-based invalidation:

// Associate cache keys with tags
async function setCached(key: string, value: any, ttl: number, tags: string[]) {
	await redis.setEx(key, ttl, JSON.stringify(value));
	for (const tag of tags) {
		await redis.sAdd(`tag:${tag}`, key);
		await redis.expire(`tag:${tag}`, ttl + 60); // tag lives a bit longer
	}
}

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

// Usage
await setCached('products:electronics', products, 3600, ['products', 'electronics']);
await setCached('products:featured', featured, 1800, ['products']);

// When any product changes:
await invalidateTag('products'); // invalidates both cache keys

CDN Caching

For public content (product pages, images, static assets):

// Express — set cache headers
app.get('/products/:id', async (req, res) => {
	const product = await getProduct(req.params.id);

	res.set({
		'Cache-Control': 'public, max-age=300, stale-while-revalidate=60',
		Vary: 'Accept-Encoding',
		ETag: `"${product.updatedAt.getTime()}"`
	});

	// Check ETag
	if (req.headers['if-none-match'] === `"${product.updatedAt.getTime()}"`) {
		return res.sendStatus(304); // not modified
	}

	res.json(product);
});

stale-while-revalidate=60: serve stale content for 60 seconds while refreshing in background. Zero latency on refresh.

Purge on update:

async function updateProduct(productId: string, data: Partial<Product>) {
	const product = await db.updateProduct(productId, data);

	// Purge CDN cache (Cloudflare example)
	await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`, {
		method: 'POST',
		headers: { Authorization: `Bearer ${CF_TOKEN}` },
		body: JSON.stringify({ files: [`https://api.example.com/products/${productId}`] })
	});

	return product;
}

What Not to Cache

✗ Data that changes faster than your TTL (real-time prices, live inventory)
✗ Data that must be consistent (account balances, order totals after payment)
✗ Data personalized per user at high cardinality (1M users × 100 products = 100M cache entries)
✗ Security-sensitive queries (permissions checks, rate limit state)
✓ Reference data (product catalog, config, localization strings)
✓ Expensive aggregations (daily stats, leaderboards)
✓ External API responses (weather, exchange rates)
✓ Session data (already in Redis anyway)

Cache hit rate below 80% often means you’re caching the wrong things, or TTLs are too short. Profile cache misses before adding more cache.