Caching Strategies for Web Applications
From HTTP headers to Redis patterns, a practical guide to caching layers that reduce latency and database load without serving stale data.

Every slow application has the same problem: it fetches data it already has. Caching is the most effective performance tool in a backend engineer's belt, but incorrect caching is worse than no caching — it serves stale data, creates consistency bugs, and gives you a false sense of performance.
The Caching Layers
Modern web applications can cache at four levels. Each has different latency characteristics and invalidation complexity.
| Layer | Latency | Invalidation | Best for |
|---|---|---|---|
| Browser cache (HTTP headers) | 0ms | Headers control it | Static assets, API responses |
| CDN/Edge cache | 1-50ms | Purge API or TTL | Public, read-heavy content |
| Application cache (Redis/memory) | 1-5ms | Application logic | Session data, computed results |
| Database query cache | 5-20ms | Auto (query-dependent) | Repeated identical queries |
Start from the top — browser caching is free and eliminates network requests entirely. Only add lower layers when upper layers aren't sufficient.
HTTP Caching Headers
The browser's built-in cache is the fastest and cheapest option. Two headers control most of it.
// ❌ No cache headers — browser re-fetches every time
app.get("/api/products", async (req, res) => {
const products = await db.products.findMany();
res.json(products);
});
// ✅ Cache headers — browser reuses response for 60 seconds
app.get("/api/products", async (req, res) => {
const products = await db.products.findMany();
res.set("Cache-Control", "public, max-age=60, s-maxage=300");
res.set("ETag", computeETag(products));
res.json(products);
});max-age tells the browser how long to use the cached response without revalidating. s-maxage tells CDNs and proxies a separate TTL. ETag enables conditional requests — the browser sends If-None-Match and the server can return 304 Not Modified without sending the body.
// Conditional request handling
app.get("/api/products", async (req, res) => {
const products = await db.products.findMany();
const etag = computeETag(products);
if (req.headers["if-none-match"] === etag) {
return res.status(304).end();
}
res.set("Cache-Control", "public, max-age=60");
res.set("ETag", etag);
res.json(products);
});Application-Level Caching with Redis
For computed or aggregated data that's expensive to regenerate, Redis is the standard choice. The pattern is cache-aside (also called lazy loading).
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
async function getProductCatalog(categoryId: string) {
const cacheKey = `catalog:${categoryId}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// 2. Cache miss — fetch from database
const products = await db.products.findMany({
where: { categoryId, status: "active" },
include: { pricing: true, inventory: true },
});
// 3. Store in cache with TTL
await redis.set(cacheKey, JSON.stringify(products), "EX", 300);
return products;
}The cache-aside pattern is simple but has a thundering herd problem: when the cache expires, all concurrent requests hit the database simultaneously.
Preventing Thundering Herds
When a popular cache key expires, hundreds of requests may simultaneously try to rebuild it. A mutex (lock) ensures only one does the work while others wait.
async function getWithLock<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>,
): 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", "EX", 10, "NX");
if (acquired) {
try {
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), "EX", ttl);
return data;
} finally {
await redis.del(lockKey);
}
}
// Another process holds the lock — wait and retry
await new Promise((resolve) => setTimeout(resolve, 100));
return getWithLock(key, ttl, fetchFn);
}An alternative is stale-while-revalidate: serve the expired cached value immediately while refreshing in the background.
Cache Invalidation Strategies
Cache invalidation is genuinely hard. Here are the practical approaches:
// Strategy 1: TTL-based — simplest, accept brief staleness
await redis.set("catalog:electronics", data, "EX", 300); // 5 min
// Strategy 2: Event-driven — invalidate on write
async function updateProduct(id: string, updates: ProductUpdate) {
const product = await db.products.update({ where: { id }, data: updates });
// Invalidate related cache keys
await redis.del(`product:${id}`);
await redis.del(`catalog:${product.categoryId}`);
return product;
}
// Strategy 3: Versioned keys — never invalidate, always fresh
async function getProductV2(id: string) {
const version = await redis.get(`product-version:${id}`);
const cacheKey = `product:${id}:v${version}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const product = await db.products.findUnique({ where: { id } });
await redis.set(cacheKey, JSON.stringify(product), "EX", 3600);
return product;
}| Strategy | Consistency | Complexity | Best for |
|---|---|---|---|
| TTL-only | Eventual (within TTL) | Low | Read-heavy, staleness OK |
| Event-driven | Strong | Medium | Write-after-read patterns |
| Versioned keys | Strong | High | High-traffic, must be fresh |
What Not to Cache
Not everything benefits from caching. Avoid caching:
- User-specific data with short sessions — cache hit rate will be near zero
- Rapidly changing data — cache invalidation cost exceeds the benefit
- Data with strict consistency requirements — financial transactions, inventory counts during checkout
- Large payloads accessed rarely — wastes memory for minimal hit rate
The first question before adding a cache should always be: what's the hit rate going to be? If it's under 50%, the cache is probably adding complexity without sufficient benefit.
Key Takeaways
- Start with HTTP caching headers — they're free and eliminate network requests entirely
- Cache-aside with Redis is the standard pattern for application-level caching
- Prevent thundering herds with mutex locks or stale-while-revalidate
- Pick an invalidation strategy based on your consistency requirements
- Measure cache hit rates — low hit rates mean you're adding complexity without benefit


