TechLead
Lesson 14 of 22
5 min read
Performance Engineering

Caching Strategies Full Stack

Implement multi-layer caching from browser to database with cache invalidation patterns and stale-while-revalidate

The Caching Pyramid

Effective caching operates at multiple layers. Each layer closer to the user provides faster responses but is typically smaller in capacity. A well-designed caching strategy uses every layer: browser cache, service worker cache, CDN/edge cache, application cache (in-memory/Redis), and database query cache.

Cache Layer Hierarchy

  • Browser Cache (~0ms): HTTP cache headers, localStorage, IndexedDB. Fastest but per-user only.
  • Service Worker Cache (~1ms): Programmable cache with offline support. Per-user, persistent.
  • CDN/Edge Cache (~10-50ms): Shared across users. Global distribution. Cache-Control driven.
  • Application Cache (~1-5ms): In-memory (LRU) or Redis. Shared across requests. Application-logic driven.
  • Database Cache (~5-20ms): Query result cache, materialized views. Managed by the database.

HTTP Caching Strategy

// Comprehensive HTTP caching for different content types

// next.config.js headers configuration
module.exports = {
  async headers() {
    return [
      // Immutable static assets (hashed filenames)
      {
        source: '/_next/static/:path*',
        headers: [{
          key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable',
        }],
      },
      // Fonts — long cache with immutable
      {
        source: '/fonts/:path*',
        headers: [{
          key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable',
        }],
      },
      // HTML pages — stale-while-revalidate pattern
      {
        source: '/((?!api).*)',
        headers: [{
          key: 'Cache-Control',
          // Browser: no cache. CDN: 60s fresh, 5min stale-while-revalidate
          value: 'public, max-age=0, s-maxage=60, stale-while-revalidate=300',
        }],
      },
      // Cacheable API endpoints
      {
        source: '/api/products/:path*',
        headers: [{
          key: 'Cache-Control',
          value: 'public, max-age=30, s-maxage=120, stale-while-revalidate=600',
        }],
      },
      // Non-cacheable API endpoints
      {
        source: '/api/user/:path*',
        headers: [{
          key: 'Cache-Control',
          value: 'private, no-cache, no-store',
        }],
      },
    ];
  },
};

Application-Level Caching

// In-memory LRU cache for Node.js applications
class LRUCache<T> {
  private cache = new Map<string, { value: T; expiry: number }>();
  private maxSize: number;

  constructor(maxSize: number = 1000) {
    this.maxSize = maxSize;
  }

  get(key: string): T | undefined {
    const entry = this.cache.get(key);
    if (!entry) return undefined;

    if (Date.now() > entry.expiry) {
      this.cache.delete(key);
      return undefined;
    }

    // Move to end (most recently used)
    this.cache.delete(key);
    this.cache.set(key, entry);
    return entry.value;
  }

  set(key: string, value: T, ttlMs: number): void {
    if (this.cache.size >= this.maxSize) {
      // Delete least recently used (first item)
      const firstKey = this.cache.keys().next().value;
      if (firstKey) this.cache.delete(firstKey);
    }
    this.cache.set(key, { value, expiry: Date.now() + ttlMs });
  }
}

// Usage in API routes
const productCache = new LRUCache<any>(500);

async function getProduct(id: string) {
  const cacheKey = `product:${id}`;
  const cached = productCache.get(cacheKey);
  if (cached) return cached;

  const product = await prisma.product.findUnique({ where: { id } });
  if (product) {
    productCache.set(cacheKey, product, 5 * 60 * 1000); // 5 min TTL
  }
  return product;
}

// Stale-while-revalidate pattern at application level
class SWRCache<T> {
  private cache = new Map<string, {
    value: T;
    staleAt: number;
    expireAt: number;
    revalidating: boolean;
  }>();

  async get(key: string, fetcher: () => Promise<T>, freshMs: number, staleMs: number): Promise<T> {
    const entry = this.cache.get(key);
    const now = Date.now();

    // Cache miss or expired
    if (!entry || now > entry.expireAt) {
      const value = await fetcher();
      this.cache.set(key, {
        value,
        staleAt: now + freshMs,
        expireAt: now + freshMs + staleMs,
        revalidating: false,
      });
      return value;
    }

    // Fresh — return cached
    if (now <= entry.staleAt) return entry.value;

    // Stale — return cached but revalidate in background
    if (!entry.revalidating) {
      entry.revalidating = true;
      fetcher().then(value => {
        this.cache.set(key, {
          value,
          staleAt: now + freshMs,
          expireAt: now + freshMs + staleMs,
          revalidating: false,
        });
      });
    }

    return entry.value;
  }
}

Cache Invalidation Patterns

// Cache invalidation strategies

// 1. Time-based expiration (TTL)
// Simplest but may serve stale data
await redis.set('product:123', JSON.stringify(product), 'EX', 3600);

// 2. Event-based invalidation
// Invalidate on write operations
async function updateProduct(id: string, data: any) {
  const product = await prisma.product.update({ where: { id }, data });

  // Invalidate specific cache entries
  await redis.del(`product:${id}`);
  await redis.del('products:listing');
  await redis.del(`category:${product.categoryId}:products`);

  // Purge CDN cache
  await fetch(`https://api.cdn.com/purge/product-${id}`, { method: 'POST' });

  // Revalidate ISR pages
  await fetch(`/api/revalidate?path=/products/${product.slug}`);

  return product;
}

// 3. Tag-based invalidation
// Group related cache entries with tags for bulk invalidation
class TaggedCache {
  constructor(private redis: any) {}

  async set(key: string, value: any, tags: string[], ttl: number) {
    const pipeline = this.redis.pipeline();
    pipeline.set(key, JSON.stringify(value), 'EX', ttl);
    for (const tag of tags) {
      pipeline.sadd(`tag:${tag}`, key);
    }
    await pipeline.exec();
  }

  async invalidateTag(tag: string) {
    const keys = await this.redis.smembers(`tag:${tag}`);
    if (keys.length > 0) {
      await this.redis.del(...keys);
    }
    await this.redis.del(`tag:${tag}`);
  }
}

// Usage
const cache = new TaggedCache(redis);
await cache.set('product:123', product, ['products', 'category:electronics'], 3600);
// Invalidate all electronics products at once:
await cache.invalidateTag('category:electronics');

Caching Best Practices

  • Cache at every layer: Browser, CDN, application, and database
  • Use stale-while-revalidate: Serve stale data while refreshing in the background
  • Tag cache entries: Enable targeted invalidation of related cache entries
  • Set appropriate TTLs: Balance freshness requirements with cache hit rates
  • Monitor hit rates: Track and optimize cache effectiveness at each layer

Continue Learning