Skip to content

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.

3 min read
Layered caching architecture diagram showing browser, CDN, application, and database cache levels

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.

LayerLatencyInvalidationBest for
Browser cache (HTTP headers)0msHeaders control itStatic assets, API responses
CDN/Edge cache1-50msPurge API or TTLPublic, read-heavy content
Application cache (Redis/memory)1-5msApplication logicSession data, computed results
Database query cache5-20msAuto (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.

tstypescript
// ❌ 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.

tstypescript
// 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).

tstypescript
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.

tstypescript
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:

tstypescript
// 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;
}
StrategyConsistencyComplexityBest for
TTL-onlyEventual (within TTL)LowRead-heavy, staleness OK
Event-drivenStrongMediumWrite-after-read patterns
Versioned keysStrongHighHigh-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

  1. Start with HTTP caching headers — they're free and eliminate network requests entirely
  2. Cache-aside with Redis is the standard pattern for application-level caching
  3. Prevent thundering herds with mutex locks or stale-while-revalidate
  4. Pick an invalidation strategy based on your consistency requirements
  5. Measure cache hit rates — low hit rates mean you're adding complexity without benefit
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX