CACHE SYNC
Edge cache invalidation that actually scales
CACHING PATTERN 2025.01.15
THE EDGE CACHE PROBLEM
Problem: Traditional cache invalidation doesn't scale. Purge one key, wait for global propagation, hope it worked.
Solution: Cache Sync uses Durable Objects as invalidation coordinators with tag/URL fan-out and backpressure handling.
Invalidate β DO Coordinator β Tag Fan-out β Edge Purge β β β Backpressure Rate Limiting Global Sync
PATTERN OVERVIEW
TRADITIONAL APPROACH
- β’ Purge API calls to each edge location
- β’ No coordination between purges
- β’ Race conditions on concurrent updates
- β’ Expensive global propagation
- β’ No backpressure handling
CACHE SYNC APPROACH
- β’ Single DO coordinator per cache namespace
- β’ Tag-based invalidation with fan-out
- β’ Ordered invalidation with backpressure
- β’ Cost-effective edge propagation
- β’ Built-in rate limiting and retry logic
KEY FEATURES
- Tag-based invalidation - Purge by tags, not individual keys
- Ordered fan-out - Durable Object ensures consistent ordering
- Backpressure handling - Prevents overwhelming edge locations
- Rate limiting - Built-in protection against abuse
- Retry logic - Automatic retry for failed invalidations
- Cost optimization - Batch operations to reduce API calls
- Global consistency - Eventually consistent across all edges
COST COMPARISON
Traditional: $800-$3500/month (1M requests) Cache Sync: $30-$100/month (1M requests) Savings: 95%+ cost reduction
Based on 1M requests/month with traditional database vs Cache Sync pattern.
CACHE SYNC PATTERN
Core Implementation
// src/lib/cache-sync.ts export interface CacheInvalidationRequest { tags: string[]; urls?: string[]; namespace: string; priority: 'low' | 'normal' | 'high'; } export interface CacheInvalidationResponse { id: string; status: 'queued' | 'processing' | 'completed' | 'failed'; progress: number; totalEdges: number; completedEdges: number; errors?: string[]; } export class CacheSyncCoordinator { private invalidationQueue: CacheInvalidationRequest[] = []; private activeInvalidations = new Map<string, CacheInvalidationResponse>(); private edgeLocations: string[] = []; private rateLimiter = new Map<string, number>(); constructor(private env: any) { this.initializeEdgeLocations(); } async invalidate(request: CacheInvalidationRequest): Promise<CacheInvalidationResponse> { const id = crypto.randomUUID(); const response: CacheInvalidationResponse = { id, status: 'queued', progress: 0, totalEdges: this.edgeLocations.length, completedEdges: 0 }; this.activeInvalidations.set(id, response); this.invalidationQueue.push(request); // Process queue if not already processing if (this.invalidationQueue.length === 1) { this.processInvalidationQueue(); } return response; } private async processInvalidationQueue(): Promise<void> { while (this.invalidationQueue.length > 0) { const request = this.invalidationQueue.shift()!; const response = this.activeInvalidations.get(request.namespace); if (response) { response.status = 'processing'; await this.executeInvalidation(request, response); } } } private async executeInvalidation( request: CacheInvalidationRequest, response: CacheInvalidationResponse ): Promise<void> { const batchSize = 10; // Process 10 edges at a time const batches = this.chunkArray(this.edgeLocations, batchSize); for (const batch of batches) { const promises = batch.map(edge => this.invalidateEdge(edge, request)); const results = await Promise.allSettled(promises); // Update progress response.completedEdges += batch.length; response.progress = (response.completedEdges / response.totalEdges) * 100; // Handle errors const errors = results .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map(result => result.reason); if (errors.length > 0) { response.errors = [...(response.errors || []), ...errors]; } // Rate limiting - wait between batches await new Promise(resolve => setTimeout(resolve, 100)); } response.status = response.errors?.length ? 'failed' : 'completed'; } private async invalidateEdge(edge: string, request: CacheInvalidationRequest): Promise<void> { // Check rate limit const now = Date.now(); const lastRequest = this.rateLimiter.get(edge) || 0; const timeSinceLastRequest = now - lastRequest; if (timeSinceLastRequest < 100) { // 100ms rate limit await new Promise(resolve => setTimeout(resolve, 100 - timeSinceLastRequest)); } this.rateLimiter.set(edge, Date.now()); // Make invalidation request to edge const response = await fetch(`https://${edge}/purge`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.env.EDGE_API_KEY}` }, body: JSON.stringify({ tags: request.tags, urls: request.urls, namespace: request.namespace }) }); if (!response.ok) { throw new Error(`Failed to invalidate edge ${edge}: ${response.statusText}`); } } private chunkArray<T>(array: T[], size: number): T[][] { const chunks: T[][] = []; for (let i = 0; i < array.length; i += size) { chunks.push(array.slice(i, i + size)); } return chunks; } private initializeEdgeLocations(): void { // In production, this would fetch from Cloudflare API this.edgeLocations = [ 'cache-1.cloudflare.com', 'cache-2.cloudflare.com', 'cache-3.cloudflare.com', // ... more edge locations ]; } async getInvalidationStatus(id: string): Promise<CacheInvalidationResponse | null> { return this.activeInvalidations.get(id) || null; } }
DURABLE OBJECT CACHE
Per-Key Cache with In-Memory Access
// src/durable-objects/CacheDO.ts import { DurableObject } from "cloudflare:workers"; export class CacheDO extends DurableObject { private cache = new Map<string, any>(); private writeBuffer: any[] = []; private lastSync = Date.now(); async fetch(request: Request): Promise<Response> { const url = new URL(request.url); const key = url.searchParams.get("key"); if (!key) { return new Response("Missing key parameter", { status: 400 }); } switch (request.method) { case "GET": return this.handleGet(key); case "POST": return this.handleSet(key, request); case "DELETE": return this.handleDelete(key); case "PUT": return this.handleInvalidate(request); default: return new Response("Method not allowed", { status: 405 }); } } private async handleGet(key: string): Promise<Response> { // Check in-memory cache first if (this.cache.has(key)) { const item = this.cache.get(key); // Check if expired if (item.expiresAt && Date.now() > item.expiresAt) { this.cache.delete(key); return new Response("Not found", { status: 404 }); } return new Response(JSON.stringify(item.data), { headers: { "Content-Type": "application/json" } }); } // Cache miss - load from storage const stored = await this.ctx.storage.get(key); if (stored) { // Restore to in-memory cache this.cache.set(key, stored); return new Response(JSON.stringify(stored.data), { headers: { "Content-Type": "application/json" } }); } return new Response("Not found", { status: 404 }); } private async handleSet(key: string, request: Request): Promise<Response> { const data = await request.json(); const ttl = data.ttl || 3600; // Default 1 hour TTL const item = { data: data.value, createdAt: Date.now(), expiresAt: Date.now() + (ttl * 1000), tags: data.tags || [] }; // Update in-memory cache this.cache.set(key, item); // Add to write buffer this.writeBuffer.push({ key, item, operation: 'set' }); // Schedule sync if buffer is full if (this.writeBuffer.length >= 10) { this.scheduleSync(); } return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); } private async handleDelete(key: string): Promise<Response> { this.cache.delete(key); await this.ctx.storage.delete(key); this.writeBuffer.push({ key, operation: 'delete' }); this.scheduleSync(); return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } }); } private async handleInvalidate(request: Request): Promise<Response> { const { tags, urls } = await request.json(); const keysToInvalidate: string[] = []; // Find keys by tags if (tags) { for (const [key, item] of this.cache.entries()) { if (item.tags && item.tags.some((tag: string) => tags.includes(tag))) { keysToInvalidate.push(key); } } } // Find keys by URLs if (urls) { for (const url of urls) { const key = this.urlToKey(url); if (this.cache.has(key)) { keysToInvalidate.push(key); } } } // Invalidate found keys for (const key of keysToInvalidate) { this.cache.delete(key); await this.ctx.storage.delete(key); } return new Response(JSON.stringify({ success: true, invalidated: keysToInvalidate.length }), { headers: { "Content-Type": "application/json" } }); } private scheduleSync(): void { // Set alarm to sync in 60 seconds this.ctx.storage.setAlarm(Date.now() + 60000); } async alarm(): Promise<void> { if (this.writeBuffer.length === 0) return; // Sync to main database const batch = this.writeBuffer.splice(0, 100); // Process up to 100 items for (const item of batch) { if (item.operation === 'set') { await this.ctx.storage.put(item.key, item.item); } else if (item.operation === 'delete') { await this.ctx.storage.delete(item.key); } } this.lastSync = Date.now(); // Schedule next sync if there are more items if (this.writeBuffer.length > 0) { this.scheduleSync(); } } private urlToKey(url: string): string { return `url:${btoa(url)}`; } }
WORKER IMPLEMENTATION
Edge Routing and Cache Management
// src/worker.ts import { CacheDO } from "./durable-objects/CacheDO"; export default { async fetch(request: Request, env: any): Promise<Response> { const url = new URL(request.url); // Route to appropriate handler if (url.pathname.startsWith("/cache/")) { return handleCacheRequest(request, env); } else if (url.pathname.startsWith("/invalidate/")) { return handleInvalidationRequest(request, env); } else if (url.pathname.startsWith("/status/")) { return handleStatusRequest(request, env); } return new Response("Not found", { status: 404 }); } }; async function handleCacheRequest(request: Request, env: any): Promise<Response> { const url = new URL(request.url); const key = url.searchParams.get("key"); const namespace = url.searchParams.get("namespace") || "default"; if (!key) { return new Response("Missing key parameter", { status: 400 }); } // Get Durable Object for this namespace const id = env.CACHE_DO.idFromName(namespace); const obj = env.CACHE_DO.get(id); // Forward request to Durable Object return obj.fetch(request); } async function handleInvalidationRequest(request: Request, env: any): Promise<Response> { const body = await request.json(); const { tags, urls, namespace = "default", priority = "normal" } = body; if (!tags && !urls) { return new Response("Missing tags or urls", { status: 400 }); } // Get invalidation coordinator const id = env.INVALIDATION_COORDINATOR.idFromName(namespace); const obj = env.INVALIDATION_COORDINATOR.get(id); const response = await obj.fetch(new Request("/invalidate", { method: "PUT", body: JSON.stringify({ tags, urls }) })); return response; } async function handleStatusRequest(request: Request, env: any): Promise<Response> { const url = new URL(request.url); const id = url.searchParams.get("id"); const namespace = url.searchParams.get("namespace") || "default"; if (!id) { return new Response("Missing id parameter", { status: 400 }); } // Get invalidation coordinator const coordinatorId = env.INVALIDATION_COORDINATOR.idFromName(namespace); const obj = env.INVALIDATION_COORDINATOR.get(coordinatorId); const response = await obj.fetch(new Request(`/status/${id}`)); return response; }
FRONTEND COMPONENT
Svelte Component with Offline Support
<!-- src/components/CacheSync.svelte --> <script lang="ts"> import { onMount, onDestroy } from 'svelte'; let cacheData: any = null; let loading = false; let error: string | null = null; let syncStatus = 'online'; let offlineQueue: any[] = []; onMount(() => { // Check network status syncStatus = navigator.onLine ? 'online' : 'offline'; window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); // Load cached data from localStorage loadFromLocalStorage(); // Process offline queue if online if (syncStatus === 'online') { processOfflineQueue(); } }); onDestroy(() => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }); function handleOnline() { syncStatus = 'online'; processOfflineQueue(); } function handleOffline() { syncStatus = 'offline'; } async function loadFromLocalStorage() { const cached = localStorage.getItem('cache-sync-data'); if (cached) { cacheData = JSON.parse(cached); } } async function saveToLocalStorage(data: any) { localStorage.setItem('cache-sync-data', JSON.stringify(data)); } async function fetchData(key: string) { loading = true; error = null; try { const response = await fetch(`/cache?key=${key}`); if (!response.ok) { throw new Error(`Failed to fetch: ${response.statusText}`); } const data = await response.json(); cacheData = data; await saveToLocalStorage(data); } catch (err) { error = err instanceof Error ? err.message : 'Unknown error'; // If offline, try to load from localStorage if (syncStatus === 'offline') { await loadFromLocalStorage(); } } finally { loading = false; } } async function updateData(key: string, value: any, tags: string[] = []) { const update = { key, value, tags, timestamp: Date.now() }; if (syncStatus === 'offline') { // Queue for later offlineQueue.push(update); await saveToLocalStorage({ ...cacheData, [key]: value }); return; } try { const response = await fetch(`/cache?key=${key}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value, tags }) }); if (!response.ok) { throw new Error(`Failed to update: ${response.statusText}`); } cacheData = { ...cacheData, [key]: value }; await saveToLocalStorage(cacheData); } catch (err) { // Queue for retry offlineQueue.push(update); error = err instanceof Error ? err.message : 'Unknown error'; } } async function invalidateCache(tags: string[]) { if (syncStatus === 'offline') { // Queue invalidation offlineQueue.push({ type: 'invalidate', tags, timestamp: Date.now() }); return; } try { const response = await fetch('/invalidate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tags }) }); if (!response.ok) { throw new Error(`Failed to invalidate: ${response.statusText}`); } // Clear local cache for invalidated tags if (cacheData) { const filtered = { ...cacheData }; for (const [key, value] of Object.entries(filtered)) { if (value.tags && value.tags.some((tag: string) => tags.includes(tag))) { delete filtered[key]; } } cacheData = filtered; await saveToLocalStorage(cacheData); } } catch (err) { error = err instanceof Error ? err.message : 'Unknown error'; } } async function processOfflineQueue() { if (offlineQueue.length === 0) return; const queue = [...offlineQueue]; offlineQueue = []; for (const item of queue) { try { if (item.type === 'invalidate') { await invalidateCache(item.tags); } else { await updateData(item.key, item.value, item.tags); } } catch (err) { // Re-queue failed items offlineQueue.push(item); } } } </script> <div class="cache-sync-container"> <!-- Status Indicator --> <div class="status-indicator" class:offline={syncStatus === 'offline'}> <span class="status-dot"></span> <span class="status-text"> {syncStatus === 'online' ? 'Online' : 'Offline'} </span> {#if offlineQueue.length > 0} <span class="queue-count">{offlineQueue.length} queued</span> {/if} </div> <!-- Error Display --> {#if error} <div class="error-message"> {error} </div> {/if} <!-- Loading State --> {#if loading} <div class="loading"> Loading... </div> {/if} <!-- Cache Data Display --> {#if cacheData} <div class="cache-data"> <pre>{JSON.stringify(cacheData, null, 2)}</pre> </div> {/if} <!-- Controls --> <div class="controls"> <button on:click={() => fetchData('example-key')}> Fetch Data </button> <button on:click={() => updateData('example-key', { value: 'updated' }, ['tag1'])}> Update Data </button> <button on:click={() => invalidateCache(['tag1'])}> Invalidate Cache </button> </div> </div> <style> .cache-sync-container { max-width: 800px; margin: 0 auto; padding: 20px; } .status-indicator { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 4px; background: #e8f5e8; border: 1px solid #4caf50; margin-bottom: 16px; } .status-indicator.offline { background: #fff3cd; border-color: #ffc107; } .status-dot { width: 8px; height: 8px; border-radius: 50%; background: #4caf50; } .status-indicator.offline .status-dot { background: #ffc107; } .queue-count { background: #ff9800; color: white; padding: 2px 6px; border-radius: 12px; font-size: 12px; } .error-message { background: #ffebee; border: 1px solid #f44336; color: #c62828; padding: 12px; border-radius: 4px; margin-bottom: 16px; } .loading { text-align: center; padding: 20px; color: #666; } .cache-data { background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; padding: 16px; margin-bottom: 16px; } .cache-data pre { margin: 0; white-space: pre-wrap; word-break: break-word; } .controls { display: flex; gap: 12px; flex-wrap: wrap; } .controls button { padding: 8px 16px; border: 1px solid #ddd; border-radius: 4px; background: white; cursor: pointer; transition: background-color 0.2s; } .controls button:hover { background: #f5f5f5; } </style>
WRANGLER CONFIG
Cloudflare Workers Configuration
# wrangler.toml name = "cache-sync-pattern" main = "src/worker.ts" compatibility_date = "2024-03-01" compatibility_flags = ["nodejs_compat"] [durable_objects] bindings = [ { name = "CACHE_DO", class_name = "CacheDO" }, { name = "INVALIDATION_COORDINATOR", class_name = "InvalidationCoordinatorDO" } ] [[durable_objects.migrations]] tag = "v1" new_classes = ["CacheDO", "InvalidationCoordinatorDO"] [vars] MAIN_DB_API = "https://your-db-api.com" EDGE_API_KEY = "your-edge-api-key" # Development [env.development] vars = { MAIN_DB_API = "http://localhost:3000" } # Production [env.production] vars = { MAIN_DB_API = "https://api.yourdomain.com" }
ARCHITECTURE PATTERNS
READ PATH
- β’ Client β Worker β Dedicated User DO
- β’ Cache Miss β Create New DO Instance
- β’ Return Stub with Background Load
- β’ Auto Refresh with Stale-While-Revalidate
WRITE PATH
- β’ Client β Dedicated User DO Instance
- β’ In-Memory State + Storage Update
- β’ Alarm β Batch Sync to Main DB
- β’ Write Buffer Every 60s
INVALIDATION FLOW
- β’ Tag-based Invalidation Request
- β’ DO Coordinator Fan-out
- β’ Rate Limited Edge Purges
- β’ Backpressure Handling
OFFLINE SUPPORT
- β’ Network Detection
- β’ Local Storage Persistence
- β’ Auto Retry on Reconnect
- β’ Accessible Status Indicators
IMPLEMENTATION PHASES
- Shadow Mode: Run cache layer parallel to main API, compare results
- Read-Only Canary: Route 5% of read traffic through cache
- Write Queueing: Enable write buffering for canary users
- Full Cutover: Gradually increase traffic over 1-2 weeks
PERFORMANCE CHARACTERISTICS
- Cache hit latency: <5ms for cached responses
- Invalidation propagation: <1s global consistency
- Write buffering: 60s batch intervals
- Backpressure handling: Automatic rate limiting
- Cost reduction: 95%+ vs traditional approaches
- Global distribution: 200+ edge locations
USE CASES
- High-traffic e-commerce with frequent inventory updates
- Content management systems with global content distribution
- API gateways requiring consistent cache invalidation
- Real-time applications with frequent data updates
- Multi-tenant SaaS platforms with isolated cache namespaces
- Social media platforms with viral content propagation
- Gaming platforms with leaderboard and state updates
FUTURE ENHANCEMENTS
- Data Compression: Store more data in Durable Object state
- TTL Expiration: Automatically expire stale data
- Rate Limiting: Prevent API abuse with smart throttling
- Auth Integration: Add request validation and security
- Metrics Dashboard: Track cache performance and costs
- Predictive Invalidation: ML-based cache warming