Skip to content
Back to Quality Assurance

Caching Strategy

Cache at the right layer (CDN, app, DB) with the right TTL and invalidation to avoid stale data and cache storms.

Why

Caching is the single most effective performance optimization — a cache hit returns in 1ms what a database query returns in 100ms, a 100x improvement. But caching is also the source of some of the hardest bugs — stale data (the cache shows an old price after an update), cache storms (the cache expires and 1000 requests simultaneously hit the database), and inconsistency (the write cache and read cache disagree). A caching strategy is not "add Redis and hope"; it is a deliberate choice of what to cache, at which layer, with what TTL, and how to invalidate. Get it right and the system is fast and correct; get it wrong and it is fast and wrong, which is worse than slow and correct.

When

Add caching when a query or computation is slow (>50ms) and the data changes infrequently (minutes, not seconds). Start with the CDN for static assets, then HTTP caching for semi-static responses, then application cache for expensive computations.

How

1. Choose the caching layer(s) for each data type
   - CDN (Cloudflare, Fastly): static assets (images, CSS, JS), immutable with hashed filenames
   - CDN: semi-static HTML (product page) with short TTL (60s) + stale-while-revalidate
   - Application cache (Redis): expensive query results (product catalog, recommendations)
   - Database cache: query plan cache (built-in), materialized views for aggregates
   - In-memory (LRU): per-instance hot data (user session, feature flags)

2. Set the TTL based on how stale the data can be
   - Static assets: 1 year (immutable, filename hashed for busting)
   - Product catalog: 5 minutes (changes infrequently, 5 min staleness is acceptable)
   - User profile: 60 seconds (changes more often, but 60s staleness is fine for display)
   - Real-time data (stock price, inventory count): do not cache, or cache for 1s only

3. Invalidate the cache on writes
   - Write-through: update the cache immediately after the database write
   - Event-based: publish a message on write, the cache subscriber invalidates the key
   - Tag-based: cache with tags (e.g., "product:42"), invalidate by tag on related writes
   - Version-based: include a version in the cache key, bump the version on schema change

4. Prevent cache storms (thundering herd)
   - When a key expires, 1000 requests simultaneously miss and hit the DB
   - Use a lock: the first miss acquires a lock, fetches from DB, writes cache, releases
   - Other requests serve the stale value (stale-while-revalidate) until the fresh one is ready
   - Or use request coalescing: all concurrent misses for the same key share one DB fetch

5. Monitor cache hit rate and staleness
   - Hit rate < 90% means the TTL is too short or the key space is too large
   - Track stale reads (user sees old data) — if frequent, the invalidation is too slow
   - Alert on cache failures (Redis down) — the system should degrade to DB, not crash

Example

// Redis cache with stale-while-revalidate and storm protection
import Redis from "ioredis";
const redis = new Redis();

async function cached<T>(
  key: string,
  ttl: number,           // fresh TTL in seconds
  staleTtl: number,      // stale TTL (serves stale while revalidating)
  fetch: () => Promise<T>,
): Promise<T> {
  const raw = await redis.get(key);
  if (raw) {
    const { value, expiresAt } = JSON.parse(raw);
    if (Date.now() < expiresAt) return value; // fresh hit
    // Stale — serve it but revalidate in the background (stale-while-revalidate)
    if (await redis.setnx(`${key}:lock`, "1")) {
      redis.expire(`${key}:lock`, 5); // lock for 5s to prevent storm
      fetch().then((fresh) => {
        redis.set(key, JSON.stringify({ value: fresh, expiresAt: Date.now() + ttl * 1000 }), "EX", ttl + staleTtl);
        redis.del(`${key}:lock`);
      });
    }
    return value; // serve stale while revalidating
  }
  // Cold miss — fetch and cache
  const fresh = await fetch();
  await redis.set(key, JSON.stringify({ value: fresh, expiresAt: Date.now() + ttl * 1000 }), "EX", ttl + staleTtl);
  return fresh;
}

// Usage: product catalog cached for 5 min, stale for 1 more min
const products = await cached("catalog:electronics", 300, 60, () =>
  db.query("SELECT * FROM products WHERE category = 'electronics'"),
);

// Invalidation on product update
async function updateProduct(id: string, data: Partial<Product>) {
  await db.query("UPDATE products SET ... WHERE id = $1", [id]);
  await redis.del("catalog:electronics"); // invalidate the cached catalog
}

Common Problems

  • ⚠Caching without an invalidation strategy — the cache serves the old price after an admin updates it, customers see the wrong price, and the bug is intermittent because it depends on cache TTL.
  • ⚠Setting a single long TTL for all cached data — the product catalog (changes weekly) and the inventory count (changes hourly) both cache for 10 minutes, so inventory is always stale.
  • ⚠Cache stampede when the TTL expires — 1000 concurrent requests all miss the cache, all hit the database simultaneously, and the database crashes under the sudden load spike.
  • ⚠Caching user-specific data with a shared key — user A's profile is cached under "profile", user B's request hits the same cache and sees A's data, a privacy breach.

Best Practices

  • ✓Include the user ID or session ID in the cache key for user-specific data — "profile:42" not "profile", so different users never share a cache entry.
  • ✓Use stale-while-revalidate for data that can tolerate brief staleness — the cache serves the stale value instantly and revalidates in the background, eliminating both latency and cache storms.
  • ✓Invalidate on write using tag-based or event-based invalidation — when a product is updated, invalidate the "catalog" tag, not just one key, so all derived caches are consistent.
  • ✓Monitor cache hit rate and alert when it drops below 90% — a low hit rate means the cache is not effective (wrong TTL, wrong key space) and the database is taking the full load.