Front-end Engineering Lab

Caching Patterns

Strategies for caching data to improve performance and reduce network requests

Effective caching can dramatically improve app performance and user experience. This guide covers proven caching strategies.

🎯 Goals

What we want:
✅ Reduce network requests
✅ Improve perceived performance
✅ Work offline when possible
✅ Keep data fresh
✅ Minimize memory usage

💾 Pattern 1: Memory Cache (In-Memory)

Fastest cache, lost on page reload.

class MemoryCache<T> {
  private cache = new Map<string, { data: T; timestamp: number }>();
  private ttl: number; // Time to live in ms
  
  constructor(ttlSeconds: number = 300) {
    this.ttl = ttlSeconds * 1000;
  }
  
  set(key: string, data: T): void {
    this.cache.set(key, {
      data,
      timestamp: Date.now()
    });
  }
  
  get(key: string): T | null {
    const cached = this.cache.get(key);
    
    if (!cached) return null;
    
    // Check if expired
    if (Date.now() - cached.timestamp > this.ttl) {
      this.cache.delete(key);
      return null;
    }
    
    return cached.data;
  }
  
  clear(): void {
    this.cache.clear();
  }
  
  delete(key: string): void {
    this.cache.delete(key);
  }
  
  has(key: string): boolean {
    return this.get(key) !== null;
  }
}

// Usage
const productCache = new MemoryCache<Product>(600); // 10 minutes

async function fetchProduct(id: string): Promise<Product> {
  // Check cache first
  const cached = productCache.get(id);
  if (cached) {
    console.log('Cache hit!');
    return cached;
  }
  
  // Fetch from API
  console.log('Cache miss, fetching...');
  const product = await api.getProduct(id);
  
  // Store in cache
  productCache.set(id, product);
  
  return product;
}

Pros:

  • ✅ Extremely fast
  • ✅ Simple to implement
  • ✅ No serialization needed

Cons:

  • ⚠️ Lost on page reload
  • ⚠️ Memory limited
  • ⚠️ Not shared across tabs

🗄️ Pattern 2: LocalStorage Cache

Persists across sessions, shared across tabs.

class LocalStorageCache<T> {
  private prefix: string;
  private ttl: number;
  
  constructor(prefix: string = 'cache', ttlSeconds: number = 3600) {
    this.prefix = prefix;
    this.ttl = ttlSeconds * 1000;
  }
  
  set(key: string, data: T): void {
    const item = {
      data,
      timestamp: Date.now()
    };
    
    try {
      localStorage.setItem(
        `${this.prefix}:${key}`,
        JSON.stringify(item)
      );
    } catch (error) {
      // Storage full, clear old items
      this.clearExpired();
    }
  }
  
  get(key: string): T | null {
    const itemStr = localStorage.getItem(`${this.prefix}:${key}`);
    
    if (!itemStr) return null;
    
    try {
      const item = JSON.parse(itemStr);
      
      // Check if expired
      if (Date.now() - item.timestamp > this.ttl) {
        this.delete(key);
        return null;
      }
      
      return item.data;
    } catch {
      return null;
    }
  }
  
  delete(key: string): void {
    localStorage.removeItem(`${this.prefix}:${key}`);
  }
  
  clearExpired(): void {
    const keys = Object.keys(localStorage);
    
    keys.forEach(key => {
      if (key.startsWith(this.prefix)) {
        const itemStr = localStorage.getItem(key);
        if (itemStr) {
          try {
            const item = JSON.parse(itemStr);
            if (Date.now() - item.timestamp > this.ttl) {
              localStorage.removeItem(key);
            }
          } catch {}
        }
      }
    });
  }
}

// Usage
const cache = new LocalStorageCache<Product>('products', 3600);

async function fetchProduct(id: string): Promise<Product> {
  const cached = cache.get(id);
  if (cached) return cached;
  
  const product = await api.getProduct(id);
  cache.set(id, product);
  
  return product;
}

Pros:

  • ✅ Persists across sessions
  • ✅ Shared across tabs
  • ✅ ~10MB storage

Cons:

  • ⚠️ Synchronous API (blocks main thread)
  • ⚠️ String serialization required
  • ⚠️ Size limit

🔧 Pattern 3: IndexedDB Cache

Large-scale caching with async API.

class IndexedDBCache {
  private dbName: string;
  private storeName: string;
  private db: IDBDatabase | null = null;
  
  constructor(dbName: string = 'app-cache', storeName: string = 'cache') {
    this.dbName = dbName;
    this.storeName = storeName;
  }
  
  async init(): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);
      
      request.onerror = () => reject(request.error);
      request.onsuccess = () => {
        this.db = request.result;
        resolve();
      };
      
      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.storeName)) {
          db.createObjectStore(this.storeName, { keyPath: 'key' });
        }
      };
    });
  }
  
  async set<T>(key: string, data: T, ttl: number = 3600): Promise<void> {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      
      const item = {
        key,
        data,
        timestamp: Date.now(),
        ttl: ttl * 1000
      };
      
      const request = store.put(item);
      
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
  
  async get<T>(key: string): Promise<T | null> {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readonly');
      const store = transaction.objectStore(this.storeName);
      const request = store.get(key);
      
      request.onsuccess = () => {
        const item = request.result;
        
        if (!item) {
          resolve(null);
          return;
        }
        
        // Check expiration
        if (Date.now() - item.timestamp > item.ttl) {
          this.delete(key);
          resolve(null);
          return;
        }
        
        resolve(item.data);
      };
      
      request.onerror = () => reject(request.error);
    });
  }
  
  async delete(key: string): Promise<void> {
    if (!this.db) await this.init();
    
    return new Promise((resolve, reject) => {
      const transaction = this.db!.transaction([this.storeName], 'readwrite');
      const store = transaction.objectStore(this.storeName);
      const request = store.delete(key);
      
      request.onsuccess = () => resolve();
      request.onerror = () => reject(request.error);
    });
  }
}

// Usage
const cache = new IndexedDBCache();

async function fetchProduct(id: string): Promise<Product> {
  const cached = await cache.get<Product>(id);
  if (cached) return cached;
  
  const product = await api.getProduct(id);
  await cache.set(id, product, 3600);
  
  return product;
}

Pros:

  • ✅ Large storage (~100MB+)
  • ✅ Async API (non-blocking)
  • ✅ Can store blobs, files

Cons:

  • ⚠️ More complex API
  • ⚠️ Async overhead

🌐 Pattern 4: HTTP Cache (Cache-Control)

Let the browser handle caching.

// API responses with proper cache headers
app.get('/api/products/:id', (req, res) => {
  const product = getProduct(req.params.id);
  
  // Cache for 1 hour
  res.setHeader('Cache-Control', 'public, max-age=3600');
  
  // Or cache with revalidation
  res.setHeader('Cache-Control', 'public, max-age=3600, must-revalidate');
  
  // Or no cache
  res.setHeader('Cache-Control', 'no-store');
  
  res.json(product);
});

// Client side - browser automatically caches!
async function fetchProduct(id: string): Promise<Product> {
  const response = await fetch(`/api/products/${id}`);
  // Browser checks cache first automatically
  return response.json();
}

Cache-Control Options

// Public - can be cached by browsers and CDNs
'public, max-age=3600'

// Private - only cached by browser, not CDNs
'private, max-age=3600'

// Immutable - never changes
'public, max-age=31536000, immutable'

// Revalidate - must check with server
'public, max-age=3600, must-revalidate'

// No cache - fetch every time
'no-store'

// Cache but revalidate every time
'no-cache'

Pros:

  • ✅ No JS code needed
  • ✅ Browser optimized
  • ✅ Works offline (Service Worker)

Cons:

  • ⚠️ Less control
  • ⚠️ Backend dependent

🔄 Pattern 5: Stale-While-Revalidate

Show cached data immediately, update in background.

class StaleWhileRevalidateCache<T> {
  private cache = new MemoryCache<T>(3600);
  
  async fetch(
    key: string, 
    fetcher: () => Promise<T>
  ): Promise<T> {
    const cached = this.cache.get(key);
    
    if (cached) {
      // Return cached immediately
      // Then update in background
      this.revalidate(key, fetcher);
      return cached;
    }
    
    // No cache, fetch and wait
    const data = await fetcher();
    this.cache.set(key, data);
    return data;
  }
  
  private async revalidate(
    key: string, 
    fetcher: () => Promise<T>
  ): Promise<void> {
    try {
      const fresh = await fetcher();
      this.cache.set(key, fresh);
    } catch (error) {
      console.error('Revalidation failed:', error);
    }
  }
}

// Usage
const cache = new StaleWhileRevalidateCache<Product>();

async function fetchProduct(id: string): Promise<Product> {
  return cache.fetch(id, () => api.getProduct(id));
}

// First call: Waits for API
// Second call: Returns cached, updates in background
// User always gets instant response!

Pros:

  • ✅ Instant perceived performance
  • ✅ Always fresh eventually
  • ✅ Resilient to network issues

Cons:

  • ⚠️ May show stale data briefly
  • ⚠️ Extra network requests

🎯 Pattern 6: Cache Invalidation

Know when to clear cache.

class CacheWithInvalidation<T> {
  private cache = new MemoryCache<T>(3600);
  private dependencies = new Map<string, Set<string>>();
  
  set(key: string, data: T, deps: string[] = []): void {
    this.cache.set(key, data);
    
    // Track dependencies
    deps.forEach(dep => {
      if (!this.dependencies.has(dep)) {
        this.dependencies.set(dep, new Set());
      }
      this.dependencies.get(dep)!.add(key);
    });
  }
  
  get(key: string): T | null {
    return this.cache.get(key);
  }
  
  invalidate(dep: string): void {
    const keys = this.dependencies.get(dep);
    
    if (keys) {
      keys.forEach(key => this.cache.delete(key));
      this.dependencies.delete(dep);
    }
  }
}

// Usage
const cache = new CacheWithInvalidation<Product>();

// Fetch product
const product = await api.getProduct('123');
cache.set('product:123', product, ['products', 'product:123']);

// Update product
await api.updateProduct('123', { name: 'New Name' });

// Invalidate all products
cache.invalidate('products');
cache.invalidate('product:123');

Common Invalidation Strategies

// 1. Time-based (TTL)
cache.set(key, data, { ttl: 3600 });

// 2. Event-based
eventBus.on('product:updated', (id) => {
  cache.invalidate(`product:${id}`);
});

// 3. Mutation-based (after write)
async function updateProduct(id: string, data: Partial<Product>) {
  await api.updateProduct(id, data);
  cache.invalidate(`product:${id}`);
  cache.invalidate('products:list');
}

// 4. Version-based
cache.set(key, { data, version: 1 });
// Later, check version
if (cached.version !== currentVersion) {
  cache.delete(key);
}

📊 Pattern 7: Multi-Layer Cache

Combine multiple cache strategies.

class MultiLayerCache<T> {
  private memoryCache = new MemoryCache<T>(300); // 5 minutes
  private storageCache = new LocalStorageCache<T>('cache', 3600); // 1 hour
  
  async get(key: string): Promise<T | null> {
    // Try memory first (fastest)
    const memory = this.memoryCache.get(key);
    if (memory) {
      console.log('Memory cache hit');
      return memory;
    }
    
    // Try storage (slower)
    const storage = this.storageCache.get(key);
    if (storage) {
      console.log('Storage cache hit');
      // Populate memory cache
      this.memoryCache.set(key, storage);
      return storage;
    }
    
    return null;
  }
  
  set(key: string, data: T): void {
    this.memoryCache.set(key, data);
    this.storageCache.set(key, data);
  }
  
  delete(key: string): void {
    this.memoryCache.delete(key);
    this.storageCache.delete(key);
  }
}

// Usage
const cache = new MultiLayerCache<Product>();

async function fetchProduct(id: string): Promise<Product> {
  const cached = await cache.get(id);
  if (cached) return cached;
  
  const product = await api.getProduct(id);
  cache.set(id, product);
  
  return product;
}

🏢 Real-World Examples

Twitter

// Memory cache for timeline
// IndexedDB for media
// Service Worker for offline
// Stale-while-revalidate for instant UX

Facebook

// Multi-layer caching
// Aggressive prefetching
// Optimistic updates
// Complex invalidation logic

Netflix

// CDN for video chunks
// IndexedDB for metadata
// Predictive caching
// Adaptive cache size

📚 Key Takeaways

  1. Start with memory cache - Simple and fast
  2. Add LocalStorage for persistence
  3. Use IndexedDB for large data
  4. Stale-while-revalidate for best UX
  5. Invalidate aggressively - Fresh > Fast
  6. Multi-layer for best performance
  7. Monitor cache size - Don't leak memory

Cache strategically, not blindly. Measure impact before optimizing.

On this page