rade frontend caching layer requires three architectural decisions: layered storage, explicit key normalization, and background revalidation. The implementation below uses TypeScript, IndexedDB for persistence, and a fetch wrapper that enforces stale-while-revalidate semantics.
Step 1: Define Cache Layers
- Memory Cache: Volatile, synchronous, ultra-fast. Holds active requests and recently accessed data.
- IndexedDB: Persistent, asynchronous, high-capacity. Stores serialized responses with TTL and versioning.
- Network: Source of truth. Used when cache misses occur, TTL expires, or explicit invalidation triggers.
Step 2: Implement the Cache Manager
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface CacheEntry {
data: unknown;
timestamp: number;
ttl: number;
version: string;
}
interface MyDB extends DBSchema {
cache: {
key: string;
value: CacheEntry;
};
}
export class CacheManager {
private memoryCache = new Map<string, CacheEntry>();
private db: IDBPDatabase<MyDB> | null = null;
private readonly version = 'v1';
async init() {
this.db = await openDB<MyDB>('app-cache', 1, {
upgrade(db) {
db.createObjectStore('cache');
},
});
}
private generateKey(url: string, params?: Record<string, unknown>): string {
const base = `${url}?${JSON.stringify(params ?? {})}`;
return `${this.version}:${btoa(unescape(encodeURIComponent(base)))}`;
}
async get<T>(url: string, params?: Record<string, unknown>): Promise<T | null> {
const key = this.generateKey(url, params);
// 1. Check memory
const memEntry = this.memoryCache.get(key);
if (memEntry && Date.now() - memEntry.timestamp < memEntry.ttl) {
return memEntry.data as T;
}
// 2. Check IndexedDB
if (!this.db) return null;
const dbEntry = await this.db.get('cache', key);
if (dbEntry && Date.now() - dbEntry.timestamp < dbEntry.ttl) {
this.memoryCache.set(key, dbEntry);
return dbEntry.data as T;
}
return null;
}
async set<T>(url: string, data: T, ttl: number = 300_000, params?: Record<string, unknown>) {
const key = this.generateKey(url, params);
const entry: CacheEntry = { data, timestamp: Date.now(), ttl, version: this.version };
this.memoryCache.set(key, entry);
if (this.db) {
await this.db.put('cache', entry, key);
}
}
async invalidate(url: string, params?: Record<string, unknown>) {
const key = this.generateKey(url, params);
this.memoryCache.delete(key);
if (this.db) {
await this.db.delete('cache', key);
}
}
async revalidate<T>(url: string, fetcher: () => Promise<T>, params?: Record<string, unknown>, ttl: number = 300_000): Promise<T> {
const cached = await this.get<T>(url, params);
const fresh = await fetcher();
await this.set(url, fresh, ttl, params);
return cached ?? fresh;
}
}
Step 3: Integrate with Data Fetching
Wrap your API calls to leverage the cache manager. The pattern returns stale data immediately if available, then fetches fresh data in the background.
const cache = new CacheManager();
await cache.init();
export async function fetchWithCache<T>(
url: string,
options?: RequestInit,
ttl?: number
): Promise<T> {
const cached = await cache.get<T>(url);
// Fire background revalidation
fetch(url, options)
.then(res => res.json())
.then(data => cache.set(url, data, ttl))
.catch(() => {}); // Silent fail for background updates
return cached ?? (await fetch(url, options).then(res => res.json()));
}
Step 4: Add Service Worker Interception (Optional but Recommended)
For static assets and repeat API calls, register a service worker that implements network-first with cache fallback.
// sw.js
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
fetch(event.request)
.then(res => {
const clone = res.clone();
caches.open('api-cache').then(c => c.put(event.request, clone));
return res;
})
.catch(() => caches.match(event.request))
);
}
});
Architecture Rationale
- Layered storage prevents main-thread blocking while maintaining persistence. Memory cache satisfies synchronous reads; IndexedDB survives navigation and tab closures.
- Key normalization via base64-encoded URL+params prevents collisions across routes, query variations, and authenticated sessions.
- Stale-while-revalidate decouples UI rendering from network latency. Users see instant data; the cache updates silently without blocking interaction.
- TTL + versioning enables safe schema migrations. Changing
this.version automatically invalidates all old entries without manual cleanup.
Pitfall Guide
1. Unbounded Cache Growth
Storing responses without eviction policies inevitably triggers quota limits or memory pressure. IndexedDB caps vary by browser (typically 6% of disk or 2GB+), but uncontrolled writes cause QuotaExceededError. Implement LRU eviction or strict TTL decay. Drop entries older than 2x TTL during low-activity windows.
2. Cache Key Collisions
Using raw URLs as keys fails when query order changes, authentication tokens shift, or route parameters mutate. Always normalize keys by sorting parameters, stripping session-specific tokens, and hashing the canonical string. Namespace keys by feature or data domain to isolate invalidation scopes.
3. Synchronous Cache Reads Blocking the Main Thread
localStorage and synchronous IndexedDB polyfills freeze the UI during large reads/writes. Modern IndexedDB is fully asynchronous. Never block render cycles waiting for disk I/O. Preload critical paths into memory cache during app initialization, and use requestIdleCallback for background writes.
Bypassing Cache-Control, ETag, and If-None-Match duplicates browser-level caching logic. If your API returns stale-while-revalidate=60, let the browser handle it for static assets. Reserve application-level caching for dynamic, authenticated, or composite endpoints that bypass HTTP caching.
5. Silent Cache Failures Without Graceful Degradation
When IndexedDB fails (private browsing, storage permissions, quota limits), applications crash or hang. Wrap all cache operations in try/catch blocks. Fall back to network fetches transparently. Log cache failures to telemetry, but never block UI rendering on cache availability.
6. Over-Reliance on Service Workers for Dynamic Data
Service workers excel at static asset caching and offline fallbacks, but they lack fine-grained invalidation control. Using SWs for user-specific API responses creates sync nightmares across tabs. Restrict SWs to public, immutable, or semi-static routes. Keep dynamic data caching in the main thread with explicit invalidation hooks.
Best Practices from Production
- Monitor hit/miss ratios: Instrument cache managers to report hit rates, TTL expirations, and invalidation triggers. Aim for >75% hit rates on repeatable queries.
- Version aggressively: Increment cache versions on schema changes, API contract updates, or feature flags. Old caches become liabilities, not assets.
- Isolate invalidation scopes: Invalidate by resource type, not by URL. When a user updates their profile, invalidate all cached profile fragments, not just the exact endpoint.
- Debounce rapid revalidations: Prevent cache thrashing by coalescing identical in-flight requests. Use a pending request map to deduplicate concurrent fetches for the same key.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public documentation / marketing site | Service Worker + Cache-First | Assets are immutable; network requests are unnecessary after first load | Reduces CDN egress by 60-80% |
| User dashboard with frequent updates | In-Memory + IndexedDB + Stale-While-Revalidate | Balances instant UI with background freshness; avoids blocking renders | Increases storage overhead by ~15MB/session |
| Real-time chat / live metrics | No Client Cache + WebSocket/SSE | Caching introduces unacceptable staleness; push updates are required | Higher bandwidth usage, lower latency |
| E-commerce product catalog | HTTP Cache Headers + IndexedDB LRU | Product data changes infrequently; native caching handles CDN edge efficiently | Minimal implementation cost, high cache hit rate |
Configuration Template
// cache.config.ts
export const CACHE_CONFIG = {
version: '2024.06',
ttl: {
static: 3600_000,
dynamic: 300_000,
realTime: 0,
auth: 1800_000,
},
eviction: {
strategy: 'LRU',
maxSize: 500,
cleanupInterval: 60_000,
},
fallback: {
networkOnQuotaExceeded: true,
silentFailOnError: true,
},
};
// Initialize
import { CacheManager } from './CacheManager';
export const cache = new CacheManager();
cache.init().catch(console.warn);
Quick Start Guide
- Install
idb (npm i idb) and create CacheManager.ts with the provided implementation.
- Replace direct
fetch() calls with fetchWithCache() or your data library's queryClient.fetchQuery wrapper, passing explicit TTL values.
- Add cache invalidation hooks to mutation endpoints (e.g.,
cache.invalidate('/api/user/profile') after POST/PUT).
- Register
sw.js in your build pipeline using vite-plugin-pwa or workbox-webpack-plugin, restricting it to static asset routes.
- Run Lighthouse and check the "Cache" audit; verify hit rates in your telemetry dashboard within 24 hours of deployment.