PatternsData Fetching Strategies
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
// Memory cache for timeline
// IndexedDB for media
// Service Worker for offline
// Stale-while-revalidate for instant UX// Multi-layer caching
// Aggressive prefetching
// Optimistic updates
// Complex invalidation logicNetflix
// CDN for video chunks
// IndexedDB for metadata
// Predictive caching
// Adaptive cache size📚 Key Takeaways
- Start with memory cache - Simple and fast
- Add LocalStorage for persistence
- Use IndexedDB for large data
- Stale-while-revalidate for best UX
- Invalidate aggressively - Fresh > Fast
- Multi-layer for best performance
- Monitor cache size - Don't leak memory
Cache strategically, not blindly. Measure impact before optimizing.