Frontend Caching Architecture: Browser, CDN, RSC, and Application Cache Coherence
Frontend Caching Architecture: Browser, CDN, RSC, and Application Cache Coherence
Not "what is caching." Instead: cache invalidation strategies, stale data risk analysis, consistency models, race conditions, and real-world failure modes. Distributed systems thinking applied to frontend.
The Cache Coherence Problem
Frontend applications now have four or more independent caching layers, each making decisions without knowledge of the others:
┌─────────────────────────────────────────────────────────────────┐
│ The Incoherence Problem │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Time Browser CDN Next.js Database │
│ ───── ─────── ─── ─────── ──────── │
│ │
│ T=0 price: $100 price: $100 price: $100 $100 │
│ (cached) (cached) (cached) │
│ │
│ T=10s Admin updates price to $80 $80 │
│ │
│ T=15s price: $100 price: $100 price: $100 $80 │
│ (stale) (stale) (stale) (fresh) │
│ │
│ T=20s User adds to cart at $100... but charges $80 │
│ ├── Trust violated │
│ ├── Support ticket opened │
│ └── Revenue lost or margin hit │
│ │
│ T=60s CDN cache expires, propagates to browser │
│ price: $80 price: $80 price: $80 $80 │
│ │
│ Window of Inconsistency: 50 seconds │
│ Affected Users: Anyone who visited between T=10s and T=60s │
│ │
└─────────────────────────────────────────────────────────────────┘
Cache Layer Inventory
Layer 1: Browser Cache
// HTTP Cache (managed by Cache-Control headers)
interface BrowserCacheEntry {
url: string;
response: Response;
expires: Date; // Derived from max-age
etag?: string; // For validation
lastModified?: Date; // For validation
immutable: boolean; // Never revalidate
}
// Service Worker Cache (application-controlled)
interface ServiceWorkerCache {
name: string;
entries: Map<string, Response>;
strategy: 'cache-first' | 'network-first' | 'stale-while-revalidate';
}
// React Query / SWR Cache (in-memory)
interface QueryCache {
queryKey: unknown[];
data: unknown;
dataUpdatedAt: number;
staleTime: number;
gcTime: number;
isStale: boolean;
}
// bfcache (Back/Forward Cache)
// Entire page state preserved for instant back navigation
// Invalidation: must be explicitly handled
Layer 2: CDN Edge Cache
interface CDNCacheEntry {
url: string;
response: Response;
edgeTTL: number; // How long edge caches
browserTTL: number; // What edge tells browser
surrogateKeys: string[]; // For targeted invalidation
vary: string[]; // Cache key variations
staleWhileRevalidate: number;
staleIfError: number;
}
// Surrogate keys enable targeted invalidation
// Example: /products/123 tagged with ['products', 'product-123', 'category-electronics']
// Purging 'category-electronics' invalidates all products in that category
Layer 3: Application Cache (Next.js)
// Next.js Data Cache
// Persists fetch() results across requests
interface DataCacheEntry {
key: string; // Derived from fetch URL + options
value: unknown;
revalidate: number; // Seconds until stale
tags: string[]; // For revalidateTag()
createdAt: number;
}
// Full Route Cache
// Pre-rendered HTML + RSC payload
interface RouteCacheEntry {
path: string;
html: string;
rscPayload: string;
revalidate: number;
tags: string[];
}
// Request Memoization (React cache())
// Dedupes identical requests within single render
// Scope: single request, not persisted
Layer 4: Database/Backend Cache
interface BackendCacheEntry {
key: string;
value: unknown;
ttl: number;
version: number;
dependencies: string[]; // What would invalidate this
}
// Redis, Memcached, or in-process LRU
// Often the "source of truth" for invalidation signals
Invalidation Strategies
Strategy 1: Time-Based Expiration (TTL)
// Simple but causes inconsistency windows
const TTL_CONFIG = {
// Align TTLs to minimize inconsistency
cdnEdge: 60, // 60 seconds at edge
cdnBrowser: 0, // Don't let browser cache
appCache: 60, // Match CDN
reactQuery: 30, // Slightly shorter for fresher client data
};
// Problem: 60-second window where stale data is served
// Solution: Use shorter TTLs for volatile data, longer for stable
function getCacheTTL(dataType: string): number {
const volatility: Record<string, number> = {
'inventory': 10, // Changes frequently
'price': 30, // Important to be fresh
'product-details': 300, // Rarely changes
'static-content': 86400, // Almost never changes
};
return volatility[dataType] ?? 60;
}
Strategy 2: Event-Driven Invalidation
// Invalidate immediately when data changes
import { revalidateTag, revalidatePath } from 'next/cache';
// Event handler for data changes
async function onProductUpdated(product: Product): Promise<void> {
// 1. Invalidate Next.js Data Cache
revalidateTag(`product-${product.id}`);
revalidateTag('products-list');
// 2. Invalidate CDN
await invalidateCDN([
`product-${product.id}`,
`category-${product.categoryId}`,
'products',
]);
// 3. Notify connected clients (for client cache)
await broadcast({
type: 'invalidation',
entities: ['product', product.id],
timestamp: Date.now(),
});
}
// CDN invalidation
async function invalidateCDN(tags: string[]): Promise<void> {
// Cloudflare
await fetch(`https://api.cloudflare.com/client/v4/zones/${ZONE}/purge_cache`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}` },
body: JSON.stringify({ tags }),
});
// Vercel
await fetch('https://api.vercel.com/v1/purge', {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}` },
body: JSON.stringify({ tags }),
});
}
// Client-side invalidation listener
function useCacheInvalidation() {
const queryClient = useQueryClient();
useEffect(() => {
const ws = new WebSocket('/api/invalidation-stream');
ws.onmessage = (event) => {
const { type, entities, timestamp } = JSON.parse(event.data);
if (type === 'invalidation') {
const [entityType, entityId] = entities;
queryClient.invalidateQueries({
queryKey: [entityType, entityId],
});
}
};
return () => ws.close();
}, [queryClient]);
}
Strategy 3: Version-Based Cache Busting
// Include version in cache keys
interface CacheVersion {
global: number;
products: number;
users: number;
settings: number;
}
// Store versions in fast storage (Redis)
const versions = await redis.hgetall('cache-versions');
// Bump version on change
async function bumpVersion(entity: keyof CacheVersion): Promise<number> {
return redis.hincrby('cache-versions', entity, 1);
}
// Include version in cache key
function getCacheKey(entity: string, id: string, version: number): string {
return `${entity}:${id}:v${version}`;
}
// All existing caches for that entity become orphaned
// No explicit invalidation needed - they simply won't be accessed
// Usage in fetch
const version = await redis.hget('cache-versions', 'products');
const product = await fetch(`/api/products/${id}`, {
next: {
revalidate: 3600, // Can use long TTL since version handles freshness
tags: [`product-${id}-v${version}`],
},
});
Strategy 4: Optimistic Invalidation
// Invalidate before the operation completes
async function updateProduct(id: string, data: ProductUpdate) {
// 1. Invalidate caches FIRST
await Promise.all([
revalidateTag(`product-${id}`),
invalidateCDN([`product-${id}`]),
queryClient.invalidateQueries({ queryKey: ['product', id] }),
]);
// 2. Then perform the update
const updated = await db.products.update({
where: { id },
data,
});
return updated;
}
// Risk: If update fails, we've invalidated unnecessarily
// But: Better than serving stale data if update succeeds
// Mitigation: Use distributed transactions or saga pattern
async function updateProductSafe(id: string, data: ProductUpdate) {
try {
const updated = await db.products.update({
where: { id },
data,
});
// Invalidate only on success
await invalidateProductCaches(id);
return updated;
} catch (error) {
// No cache invalidation on failure
throw error;
}
}
Consistency Models
Strong Consistency
// Every read sees the latest write
// Implementation: Bypass all caches
async function getProductStrong(id: string): Promise<Product> {
const response = await fetch(`/api/products/${id}`, {
cache: 'no-store', // Skip HTTP cache
headers: {
'CDN-Cache-Control': 'no-store', // Skip CDN
},
});
return response.json();
}
// Cost: Higher latency, more origin load
// Use for: Checkout, inventory checks, auth state
Eventual Consistency
// Reads may see stale data, but will eventually see latest
// Implementation: Use caches with TTL
async function getProductEventual(id: string): Promise<Product> {
const response = await fetch(`/api/products/${id}`, {
next: { revalidate: 60 }, // Stale for up to 60 seconds
});
return response.json();
}
// Cost: May serve stale data
// Use for: Product listings, content, non-critical data
Session Consistency (Read-Your-Writes)
// User always sees their own writes
class SessionConsistentClient {
private writeTimestamps: Map<string, number> = new Map();
async write(entity: string, id: string, data: unknown): Promise<void> {
await api.update(entity, id, data);
this.writeTimestamps.set(`${entity}:${id}`, Date.now());
}
async read<T>(entity: string, id: string): Promise<T> {
const lastWrite = this.writeTimestamps.get(`${entity}:${id}`);
if (lastWrite && Date.now() - lastWrite < 60000) {
// Recent write - bypass cache
return api.get(entity, id, { cache: 'no-store' });
}
// No recent write - use cache
return api.get(entity, id);
}
}
// HTTP header approach
async function fetchWithSessionConsistency(url: string): Promise<Response> {
const lastWrite = sessionStorage.getItem('lastWriteTimestamp');
return fetch(url, {
headers: {
'X-Last-Write': lastWrite ?? '0',
},
});
}
// Server checks if cached version is newer than X-Last-Write
Causal Consistency
// Causally related operations are seen in order
interface CausalContext {
vectorClock: Map<string, number>;
lastSeen: Map<string, number>;
}
class CausalConsistentClient {
private context: CausalContext = {
vectorClock: new Map(),
lastSeen: new Map(),
};
async read<T>(key: string): Promise<T> {
const response = await fetch(`/api/data/${key}`, {
headers: {
'X-Vector-Clock': JSON.stringify(Object.fromEntries(this.context.vectorClock)),
},
});
// Update our clock with server's
const serverClock = JSON.parse(response.headers.get('X-Vector-Clock') ?? '{}');
this.mergeClock(serverClock);
return response.json();
}
async write(key: string, value: unknown): Promise<void> {
// Increment our clock
const nodeId = 'client-' + getClientId();
this.context.vectorClock.set(
nodeId,
(this.context.vectorClock.get(nodeId) ?? 0) + 1
);
await fetch(`/api/data/${key}`, {
method: 'PUT',
headers: {
'X-Vector-Clock': JSON.stringify(Object.fromEntries(this.context.vectorClock)),
},
body: JSON.stringify(value),
});
}
private mergeClock(serverClock: Record<string, number>): void {
for (const [node, time] of Object.entries(serverClock)) {
const current = this.context.vectorClock.get(node) ?? 0;
this.context.vectorClock.set(node, Math.max(current, time));
}
}
}
Race Conditions
Race 1: Concurrent Read and Invalidation
┌─────────────────────────────────────────────────────────────────┐
│ Read During Invalidation Race │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Timeline: │
│ │
│ CDN Edge A CDN Edge B Origin │
│ ────────── ────────── ────── │
│ │
│ T=0 cache: $100 cache: $100 DB: $100 │
│ │
│ T=1 ─────────────── Invalidation sent ───────────── │
│ │ │ │
│ T=2 │ User reads Invalidation Update │ │
│ │ from A received DB: $80 │ │
│ │ Gets $100 cache cleared │ │
│ │ │ │
│ T=3 Invalidation User reads │ │
│ received Gets $80 │ │
│ cache cleared (fresh from origin) │ │
│ │
│ Result: User A sees $100, User B sees $80 at same time │
│ │
└─────────────────────────────────────────────────────────────────┘
Solution: Invalidation ordering
// Use version numbers to detect stale responses
interface VersionedResponse<T> {
data: T;
version: number;
timestamp: number;
}
async function fetchWithVersionCheck<T>(
url: string,
currentVersion: number
): Promise<{ data: T; isStale: boolean }> {
const response = await fetch(url);
const body: VersionedResponse<T> = await response.json();
return {
data: body.data,
isStale: body.version < currentVersion,
};
}
Race 2: Optimistic Update Conflict
┌─────────────────────────────────────────────────────────────────┐
│ Optimistic Update Race Condition │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User sees: Server state: React Query Cache: │
│ ────────── ───────────── ───────────────── │
│ │
│ T=0 name: "Alice" name: "Alice" name: "Alice" │
│ │
│ T=1 User types "Bob" │
│ (optimistic) name: "Alice" name: "Bob" │
│ │
│ T=2 Network request sent... │
│ │
│ T=3 Meanwhile, another user updates to "Carol" │
│ name: "Bob" name: "Carol" name: "Bob" │
│ (optimistic) │
│ │
│ T=4 Server rejects "Bob" (conflict with "Carol") │
│ Rollback to... what? │
│ - "Alice" (our original)? │
│ - "Carol" (server's current)? │
│ │
│ T=5 name: "Alice" name: "Carol" name: "Alice" │
│ (wrong!) │
│ │
└─────────────────────────────────────────────────────────────────┘
Solution: Rollback to server state on conflict
const updateUser = useMutation({
mutationFn: (data) => api.updateUser(data),
onMutate: async (newData) => {
await queryClient.cancelQueries({ queryKey: ['user', id] });
const previous = queryClient.getQueryData(['user', id]);
queryClient.setQueryData(['user', id], newData);
return { previous };
},
onError: (error, variables, context) => {
if (error.code === 'CONFLICT') {
// Fetch fresh state from server, not previous local state
queryClient.invalidateQueries({ queryKey: ['user', id] });
} else {
// Other errors - rollback to previous
queryClient.setQueryData(['user', id], context.previous);
}
},
onSuccess: () => {
// Ensure cache matches server
queryClient.invalidateQueries({ queryKey: ['user', id] });
},
});
Race 3: Parallel Requests with Different Cache States
┌─────────────────────────────────────────────────────────────────┐
│ Parallel Request Inconsistency │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Component A requests: Component B requests: │
│ /api/product/123 /api/cart (includes product) │
│ │
│ CDN returns cached: Origin returns fresh: │
│ { price: $100 } { items: [{ price: $80 }] } │
│ │
│ User sees product at $100 but cart shows $80 │
│ │
└─────────────────────────────────────────────────────────────────┘
Solution: Request coordination
// Fetch related data together
async function getProductPage(productId: string) {
const [product, cart] = await Promise.all([
fetch(`/api/products/${productId}`),
fetch('/api/cart'),
]);
// Both from same "snapshot" of time
return { product, cart };
}
// Or use server-side aggregation
async function getProductPage(productId: string) {
// Server fetches all related data atomically
return fetch(`/api/pages/product/${productId}`);
}
// Or include version tokens
interface DataWithVersion<T> {
data: T;
dataVersion: string; // e.g., "products:v42"
}
// Client can detect mismatches and refetch
Failure Modes
Failure 1: Zombie Cache Entries
// Cached data that should have been invalidated but wasn't
// Cause: Invalidation message lost
// Detection:
function detectZombie<T extends { updatedAt: Date }>(
cached: T,
maxAge: number
): boolean {
const age = Date.now() - cached.updatedAt.getTime();
return age > maxAge;
}
// Prevention: Double-invalidation with backoff
async function robustInvalidate(keys: string[]): Promise<void> {
// First invalidation
await invalidateCache(keys);
// Delayed second invalidation to catch races
setTimeout(() => invalidateCache(keys), 5000);
}
// Mitigation: Background staleness checks
async function stalenessSweep(): Promise<void> {
const entries = await getAllCacheEntries();
for (const entry of entries) {
const fresh = await fetchFresh(entry.key);
if (hasChanged(entry.value, fresh)) {
console.warn(`Zombie cache detected: ${entry.key}`);
await invalidateCache([entry.key]);
}
}
}
Failure 2: Thundering Herd on Invalidation
// Many clients simultaneously request after cache expires
// Detection
let requestCount = 0;
const REQUEST_THRESHOLD = 100;
// Prevention: Request coalescing
const inflightRequests = new Map<string, Promise<unknown>>();
async function deduplicatedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
const existing = inflightRequests.get(key);
if (existing) {
return existing as Promise<T>;
}
const promise = fetcher().finally(() => {
inflightRequests.delete(key);
});
inflightRequests.set(key, promise);
return promise;
}
// Prevention: Jittered cache expiration
function getJitteredTTL(baseTTL: number): number {
const jitter = Math.random() * 0.2; // ±10%
return baseTTL * (0.9 + jitter);
}
// Prevention: Early probabilistic refresh
function shouldRefreshEarly(ttl: number, elapsed: number, delta: number): boolean {
const remaining = ttl - elapsed;
const probability = Math.exp(-remaining / delta);
return Math.random() < probability;
}
Failure 3: Cache Poisoning
// Bad data enters cache and spreads
// Cause: Error response gets cached
// Prevention: Validate before caching
async function safeFetch<T>(url: string, validator: (data: unknown) => data is T): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
// Don't cache error responses
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!validator(data)) {
// Don't cache invalid data
throw new Error('Invalid response shape');
}
return data;
}
// Cause: Attacker manipulates cached content
// Prevention: Signed cache entries
interface SignedCacheEntry<T> {
data: T;
signature: string;
timestamp: number;
}
async function verifyAndGet<T>(entry: SignedCacheEntry<T>): Promise<T> {
const expectedSignature = await sign(entry.data, entry.timestamp);
if (entry.signature !== expectedSignature) {
throw new Error('Cache entry signature mismatch');
}
return entry.data;
}
Failure 4: Split-Brain Cache
// Different cache nodes have different views
// Cause: Network partition during invalidation
// Detection: Version vectors
interface CacheState {
version: Map<string, number>; // nodeId -> version
}
function detectSplitBrain(state1: CacheState, state2: CacheState): boolean {
// Check if either state has updates the other doesn't know about
for (const [node, version] of state1.version) {
const otherVersion = state2.version.get(node) ?? 0;
if (version > otherVersion) {
// state1 has updates state2 doesn't know about
// Check reverse
for (const [node2, version2] of state2.version) {
const otherVersion2 = state1.version.get(node2) ?? 0;
if (version2 > otherVersion2) {
// Both have updates the other doesn't know about
return true; // Split brain!
}
}
}
}
return false;
}
// Mitigation: Quorum reads
async function quorumRead<T>(key: string, nodes: CacheNode[]): Promise<T> {
const responses = await Promise.all(
nodes.map((node) => node.get(key).catch(() => null))
);
const valid = responses.filter(Boolean);
if (valid.length < nodes.length / 2 + 1) {
throw new Error('Quorum not reached');
}
// Return most recent by version
return valid.sort((a, b) => b.version - a.version)[0];
}
Observability
// Cache monitoring dashboard
interface CacheMetrics {
hitRate: number;
missRate: number;
staleServes: number;
invalidationsPerSecond: number;
avgLatency: {
hit: number;
miss: number;
};
entryCount: number;
memoryUsage: number;
}
class CacheObserver {
private metrics: Map<string, number[]> = new Map();
recordHit(layer: string, latency: number): void {
this.record(`${layer}.hit`, 1);
this.record(`${layer}.hit.latency`, latency);
}
recordMiss(layer: string, latency: number): void {
this.record(`${layer}.miss`, 1);
this.record(`${layer}.miss.latency`, latency);
}
recordStale(layer: string, staleness: number): void {
this.record(`${layer}.stale`, 1);
this.record(`${layer}.staleness`, staleness);
}
recordInvalidation(layer: string, keys: number): void {
this.record(`${layer}.invalidation`, keys);
}
getReport(): CacheMetrics {
const hits = this.sum('hit');
const misses = this.sum('miss');
const total = hits + misses;
return {
hitRate: total > 0 ? hits / total : 0,
missRate: total > 0 ? misses / total : 0,
staleServes: this.sum('stale'),
invalidationsPerSecond: this.rate('invalidation'),
avgLatency: {
hit: this.avg('hit.latency'),
miss: this.avg('miss.latency'),
},
entryCount: this.sum('entries'),
memoryUsage: this.sum('memory'),
};
}
// Alert on anomalies
checkAnomalies(): Alert[] {
const alerts: Alert[] = [];
// Low hit rate
if (this.getReport().hitRate < 0.8) {
alerts.push({
severity: 'warning',
message: 'Cache hit rate below 80%',
});
}
// High staleness
const avgStaleness = this.avg('staleness');
if (avgStaleness > 60000) {
alerts.push({
severity: 'critical',
message: `Average staleness ${avgStaleness / 1000}s exceeds threshold`,
});
}
// Thundering herd detection
const invalidationRate = this.rate('invalidation');
const missRate = this.rate('miss');
if (missRate > 100 && invalidationRate > 10) {
alerts.push({
severity: 'critical',
message: 'Potential thundering herd detected',
});
}
return alerts;
}
}
Summary
Cache coherence in frontend applications requires treating caches as a distributed system:
| Problem | Solution | Trade-off |
|---|---|---|
| Stale data | Event-driven invalidation | Complexity |
| Inconsistency window | Version-based keys | Memory overhead |
| Race conditions | Request coordination | Latency |
| Split brain | Quorum reads | Availability |
| Thundering herd | Request coalescing | Memory pressure |
| Cache poisoning | Signed entries | CPU overhead |
Key principles:
- Know your consistency requirements — Not all data needs strong consistency
- Invalidate explicitly — TTL is a fallback, not a strategy
- Version everything — Detect staleness at read time
- Coordinate invalidation — All layers must agree on freshness
- Monitor obsessively — Cache bugs are invisible until they're catastrophic
The goal: users see consistent data across all views, while still benefiting from cache performance. This is fundamentally a distributed systems problem, and requires distributed systems thinking.
What did you think?