Loading learning content...
In write-around caching, the cache doesn't receive data during writes—so how does it ever contain data? The answer is demand-driven population: the cache is filled by read operations. When a read request finds the cache empty (a cache miss), the system fetches data from the database and populates the cache for future reads.
This lazy loading approach creates a cache whose contents precisely reflect actual read patterns. Rather than speculatively caching all written data (most of which may never be read), write-around builds a cache of proven value—data that has demonstrated demand through read requests.
By the end of this page, you will understand the complete read path of write-around caching—how cache misses are handled, the mechanics of lazy population, optimization strategies for reducing miss penalties, and how demand-driven caching creates naturally efficient cache utilization.
The read path in write-around caching follows a well-defined sequence. Understanding each step—and the decisions made at each point—is essential for implementing and optimizing this strategy.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687
interface ReadResult<T> { data: T | null; source: 'cache' | 'database'; latencyMs: number;} class WriteAroundReadPath<T> { constructor( private cache: CacheStore<T>, private database: Database<T>, private defaultTtl: number = 3600 ) {} async read(key: string): Promise<ReadResult<T>> { const startTime = performance.now(); // Step 1: Cache lookup const cachedData = await this.cache.get(key); if (cachedData !== null) { // CACHE HIT: Fast path - return immediately return { data: cachedData, source: 'cache', latencyMs: performance.now() - startTime, }; } // Step 2: CACHE MISS - Query database const dbData = await this.database.get(key); if (dbData !== null) { // Step 3: Populate cache for future reads await this.cache.set(key, dbData, this.defaultTtl); } return { data: dbData, source: 'database', latencyMs: performance.now() - startTime, }; } // Batch read with optimized cache population async batchRead(keys: string[]): Promise<Map<string, ReadResult<T>>> { const results = new Map<string, ReadResult<T>>(); const startTime = performance.now(); // Step 1: Multi-get from cache const cachedEntries = await this.cache.multiGet(keys); const missedKeys: string[] = []; for (const key of keys) { if (cachedEntries.has(key)) { results.set(key, { data: cachedEntries.get(key)!, source: 'cache', latencyMs: performance.now() - startTime, }); } else { missedKeys.push(key); } } // Step 2: Batch query database for misses if (missedKeys.length > 0) { const dbEntries = await this.database.batchGet(missedKeys); // Step 3: Populate cache with all fetched data const toCache = new Map<string, T>(); for (const [key, value] of dbEntries) { toCache.set(key, value); results.set(key, { data: value, source: 'database', latencyMs: performance.now() - startTime, }); } await this.cache.multiSet(toCache, this.defaultTtl); } return results; }}Single-key reads are simple but inefficient at scale. Production systems should implement batch read operations (multiGet/multiSet) to amortize network round-trip costs. A single batch request fetching 100 keys is far faster than 100 individual requests.
Cache misses are inevitable in write-around caching—they're the mechanism that populates the cache. However, how you handle misses significantly impacts system performance, especially under load.
The Cache Stampede Problem:
When a popular cache entry expires or is first accessed after a write, multiple concurrent requests may all experience cache misses simultaneously. Without protection, all these requests hit the database in parallel—potentially overwhelming it.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879
// PROBLEM: Cache stampede (thundering herd)async function naiveRead(key: string): Promise<Data> { const cached = await cache.get(key); if (cached) return cached; // If 1000 requests arrive while cache is empty, // ALL 1000 hit the database simultaneously! const data = await database.get(key); await cache.set(key, data); return data;} // SOLUTION 1: Request coalescing with locksclass CoalescingCache<T> { private inFlight = new Map<string, Promise<T | null>>(); async read(key: string): Promise<T | null> { const cached = await this.cache.get(key); if (cached !== null) return cached; // Check if request is already in flight if (this.inFlight.has(key)) { // Wait for existing request instead of making new one return this.inFlight.get(key)!; } // First requester: execute and share result const promise = this.fetchAndCache(key); this.inFlight.set(key, promise); try { return await promise; } finally { this.inFlight.delete(key); } } private async fetchAndCache(key: string): Promise<T | null> { const data = await this.database.get(key); if (data !== null) { await this.cache.set(key, data, this.ttl); } return data; }} // SOLUTION 2: Distributed locking (Redis example)class DistributedLockCache<T> { async read(key: string): Promise<T | null> { const cached = await this.cache.get(key); if (cached !== null) return cached; const lockKey = `lock:${key}`; const lockAcquired = await this.redis.set( lockKey, 'locked', 'NX', // Only set if not exists 'EX', 5 // 5 second expiry ); if (lockAcquired) { // We hold the lock - fetch and cache try { const data = await this.database.get(key); if (data !== null) { await this.cache.set(key, data); } return data; } finally { await this.redis.del(lockKey); } } else { // Someone else is fetching - wait and retry await sleep(100); return this.read(key); // Retry after short wait } }}| Strategy | Mechanism | Pros | Cons |
|---|---|---|---|
| Naive (No Protection) | All requests hit DB | Simple | DB overwhelmed under load |
| Request Coalescing | In-memory dedup | Low latency, no external deps | Single-node only |
| Distributed Locking | Redis/etcd lock | Works across nodes | Lock overhead, complexity |
| Probabilistic Refresh | Random early refresh | No locks, self-healing | Some duplicate fetches |
| Background Refresh | Async pre-refresh | Zero user-facing miss | Complexity, resource usage |
In write-around, the first read after a write is ALWAYS a cache miss (unless you pre-populate). For frequently-accessed data updated via write-around, consider hybrid approaches: use write-around for bulk/cold data, but write-through for hot data with predictable read-after-write patterns.
Lazy loading is a design pattern where resources are loaded only when actually needed, not speculatively in advance. Write-around caching embodies this philosophy—data enters the cache only when read demand proves its value.
Why Lazy Loading Wins for Most Workloads:
In typical applications, write volume exceeds read-unique-key volume. Consider an e-commerce platform:
Not all writes lead to reads:
1234567891011121314151617181920212223242526272829303132333435363738
// E-commerce data access pattern analysis const accessPatterns = { // Order History: Writes >> Reads of specific orders orderHistory: { writes: "Every checkout creates an order", reads: "Users occasionally view past orders", readRatio: 0.05, // 5% of orders ever viewed recommendation: "Write-around (95% of cache would be waste)", }, // Product Catalog: Reads >> Writes productCatalog: { writes: "Admin updates prices, descriptions", reads: "Every page view queries products", readRatio: 100, // Each product read 100+ times recommendation: "Write-around works great (reads populate cache)", }, // User Activity Logs: Write-only activityLogs: { writes: "Every click, scroll, navigation logged", reads: "Only analytics jobs read (batch, cold)", readRatio: 0.001, // 0.1% read, months later recommendation: "Write-around essential (don't cache logs!)", }, // Shopping Cart: Read-after-write shoppingCart: { writes: "User adds/removes items", reads: "User views cart immediately after update", readRatio: 1, // 100% read after each write recommendation: "Write-through (always read after write)", },}; // Analysis: Write-around is ideal for order history, products, logs// Shopping cart is the exception - use write-through thereTypically, 80% of reads access 20% of data. Lazy loading naturally caches that 20%—the hot subset actually being read. Eager loading would cache 100% of writes, wasting 80% on data that's rarely accessed.
When data is loaded into the cache on a read miss, it's assigned a Time-To-Live (TTL). The TTL determines how long the data remains valid before it's evicted or considered stale. Choosing the right TTL is crucial for balancing freshness and cache efficiency.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
interface TTLStrategy { getTTL(key: string, data: any): number;} // Strategy 1: Fixed TTL by data typeclass TypeBasedTTL implements TTLStrategy { private ttlMap: Record<string, number> = { 'user:': 300, // 5 minutes - user profiles 'product:': 600, // 10 minutes - product catalog 'config:': 3600, // 1 hour - configurations 'session:': 1800, // 30 minutes - sessions 'stats:': 60, // 1 minute - statistics }; getTTL(key: string): number { for (const [prefix, ttl] of Object.entries(this.ttlMap)) { if (key.startsWith(prefix)) return ttl; } return 300; // Default 5 minutes }} // Strategy 2: Adaptive TTL based on access frequencyclass AdaptiveTTL implements TTLStrategy { private accessCounts = new Map<string, number>(); getTTL(key: string): number { const accessCount = this.accessCounts.get(key) || 0; this.accessCounts.set(key, accessCount + 1); // Hot data gets longer TTL (more valuable to cache) if (accessCount > 100) return 3600; // Very hot: 1 hour if (accessCount > 10) return 600; // Warm: 10 minutes return 120; // Cold: 2 minutes }} // Strategy 3: Content-aware TTLclass ContentAwareTTL implements TTLStrategy { getTTL(key: string, data: any): number { // Immutable data can have long TTL if (data.immutable) return 86400; // 24 hours // Data with known update schedule if (data.nextUpdateAt) { const untilUpdate = data.nextUpdateAt - Date.now(); return Math.max(60, Math.floor(untilUpdate / 1000)); } // Data with version indicator if (data.version) { // Aggressive caching, use version for invalidation return 3600; } return 300; // Default }} // Strategy 4: Jittered TTL (prevents thundering herd on expiry)class JitteredTTL implements TTLStrategy { constructor(private baseTTL: number, private jitterPercent = 0.1) {} getTTL(key: string): number { const jitter = this.baseTTL * this.jitterPercent; const randomJitter = (Math.random() - 0.5) * 2 * jitter; return Math.floor(this.baseTTL + randomJitter); // Example: baseTTL=600, jitterPercent=0.1 // TTL will be between 540 and 660 seconds // Prevents all entries from expiring simultaneously }}| Data Type | Recommended TTL | Strategy | Rationale |
|---|---|---|---|
| User sessions | 30 min (sliding) | Fixed + refresh on access | Security + convenience |
| Product catalog | 10-15 min (jittered) | Jittered | Balance freshness, prevent stampede |
| User profiles | 5 min | Fixed | Moderate update frequency |
| Static content | 24+ hours | Long fixed | Rarely changes |
| Real-time stats | 30-60 sec | Short fixed | Must be fresh |
| Feature flags | 1-5 min | Short + version | Quick rollout, invalidate on change |
If 10,000 entries all have exactly 600-second TTL set at the same time, they all expire at the same time—creating a cache stampede. Always add random jitter (±10% is common) to spread expirations over time.
While lazy loading is the core principle, several optimization techniques can improve cache population efficiency and reduce the impact of cache misses.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
class OptimizedCachePopulation<T> { // Technique 1: Related-key prefetching async readWithPrefetch(key: string): Promise<T | null> { const cached = await this.cache.get(key); if (cached !== null) return cached; // Fetch primary key const data = await this.database.get(key); // Prefetch related keys in background (don't await) const relatedKeys = this.getRelatedKeys(key); this.prefetchInBackground(relatedKeys); if (data !== null) { await this.cache.set(key, data); } return data; } private getRelatedKeys(key: string): string[] { // Example: Product page might need reviews, related products if (key.startsWith('product:')) { const productId = key.split(':')[1]; return [ `reviews:${productId}`, `related:${productId}`, `inventory:${productId}`, ]; } return []; } private async prefetchInBackground(keys: string[]): Promise<void> { // Fire and forget - don't block main request (async () => { const missing = await this.filterUncached(keys); if (missing.length > 0) { const data = await this.database.batchGet(missing); await this.cache.multiSet(data); } })(); } // Technique 2: Background refresh before expiry async readWithRefresh(key: string): Promise<T | null> { const result = await this.cache.getWithMetadata(key); if (result !== null) { const { data, ttlRemaining } = result; // If TTL is low, trigger background refresh if (ttlRemaining < this.refreshThreshold) { this.refreshInBackground(key); } return data; } // Cache miss - normal flow return this.fetchAndCache(key); } private async refreshInBackground(key: string): Promise<void> { // Acquire lock to prevent multiple refreshes const lockAcquired = await this.tryLock(`refresh:${key}`, 30); if (!lockAcquired) return; try { const freshData = await this.database.get(key); if (freshData !== null) { await this.cache.set(key, freshData, this.ttl); } } finally { await this.releaseLock(`refresh:${key}`); } } // Technique 3: Negative caching for missing keys async readWithNegativeCache(key: string): Promise<T | null> { const cached = await this.cache.get(key); // Check for explicit negative cache marker if (cached === NEGATIVE_CACHE_MARKER) { return null; // Known to not exist } if (cached !== null) { return cached; } const data = await this.database.get(key); if (data === null) { // Cache the negative result with short TTL await this.cache.set(key, NEGATIVE_CACHE_MARKER, 60); } else { await this.cache.set(key, data, this.ttl); } return data; }} const NEGATIVE_CACHE_MARKER = Symbol('NEGATIVE_CACHE');Without negative caching, an attacker can query non-existent keys repeatedly, forcing database queries each time. This 'cache penetration attack' can overwhelm your database. Negative caching (caching 'not found' results) blocks this attack vector.
While write-around relies on lazy loading, cache warming can pre-populate the cache with known hot data, eliminating the cold-start problem where an empty cache leads to a storm of database queries.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
interface CacheWarmer { warm(): Promise<WarmingResult>;} interface WarmingResult { keysWarmed: number; durationMs: number; errors: string[];} class WriteAroundCacheWarmer implements CacheWarmer { constructor( private cache: CacheStore<any>, private database: Database<any>, private analytics: AnalyticsService ) {} async warm(): Promise<WarmingResult> { const start = performance.now(); const errors: string[] = []; let keysWarmed = 0; try { // Strategy 1: Load known hot keys from analytics const hotKeys = await this.analytics.getTopAccessedKeys(1000); await this.warmKeys(hotKeys); keysWarmed += hotKeys.length; // Strategy 2: Load configuration data const configKeys = await this.getConfigurationKeys(); await this.warmKeys(configKeys); keysWarmed += configKeys.length; // Strategy 3: Load active user sessions const activeSessions = await this.getActiveSessionKeys(); await this.warmKeys(activeSessions); keysWarmed += activeSessions.length; // Strategy 4: Load landing page data await this.warmLandingPageData(); keysWarmed += await this.getLandingPageKeyCount(); } catch (error) { errors.push(error.message); } return { keysWarmed, durationMs: performance.now() - start, errors, }; } private async warmKeys(keys: string[]): Promise<void> { // Batch fetch from database const batchSize = 100; for (let i = 0; i < keys.length; i += batchSize) { const batch = keys.slice(i, i + batchSize); const data = await this.database.batchGet(batch); // Populate cache await this.cache.multiSet(data, this.defaultTtl); // Rate limit to avoid overwhelming database await sleep(10); } } private async warmLandingPageData(): Promise<void> { // Pre-warm data needed for the homepage const homePageData = { featuredProducts: await this.database.get('featured:products'), topCategories: await this.database.get('top:categories'), promotions: await this.database.get('active:promotions'), announcements: await this.database.get('announcements'), }; for (const [key, value] of Object.entries(homePageData)) { if (value !== null) { await this.cache.set(key, value, this.longTtl); } } }} // Startup hookasync function onApplicationStart(): Promise<void> { console.log('Starting cache warming...'); const warmer = new WriteAroundCacheWarmer(cache, database, analytics); const result = await warmer.warm(); console.log(`Cache warmed: ${result.keysWarmed} keys in ${result.durationMs}ms`); if (result.errors.length > 0) { console.warn('Warming errors:', result.errors); }}When deploying with a cold cache, consider gradual traffic shifting. Route 10% of traffic to the new instance, let it warm naturally, then increase to 50%, 100%. This prevents a thundering herd of cache misses.
In write-around caching, understanding how effectively your cache is being populated and utilized is crucial for tuning performance. Key metrics tell you whether your lazy loading strategy is working.
| Metric | Formula | Target | What It Tells You |
|---|---|---|---|
| Hit Rate | Hits / (Hits + Misses) | 90% | Cache is serving reads effectively |
| Miss Rate | Misses / (Hits + Misses) | <10% | Infrequent database fallbacks |
| Fill Rate | Unique keys / Capacity | 60-80% | Cache is well-utilized |
| Eviction Rate | Evictions / hour | Low | Cache size is adequate |
| Population Latency | Time to fill after miss | <50ms | DB fetch + cache set is fast |
| Stale Hit Rate | Stale reads / Total reads | <5% | Data is fresh enough |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
interface CacheMetrics { hits: number; misses: number; evictions: number; populationLatencyMs: number[]; staleReads: number;} class InstrumentedWriteAroundCache<T> { private metrics: CacheMetrics = { hits: 0, misses: 0, evictions: 0, populationLatencyMs: [], staleReads: 0, }; async read(key: string): Promise<T | null> { const cached = await this.cache.get(key); if (cached !== null) { this.metrics.hits++; return cached; } this.metrics.misses++; const populationStart = performance.now(); const data = await this.database.get(key); if (data !== null) { await this.cache.set(key, data); this.metrics.populationLatencyMs.push( performance.now() - populationStart ); } return data; } getMetrics(): ComputedMetrics { const total = this.metrics.hits + this.metrics.misses; return { hitRate: total > 0 ? this.metrics.hits / total : 0, missRate: total > 0 ? this.metrics.misses / total : 0, avgPopulationLatencyMs: this.average(this.metrics.populationLatencyMs), p99PopulationLatencyMs: this.percentile(this.metrics.populationLatencyMs, 0.99), totalOperations: total, }; } exportToPrometheus(): string[] { const metrics = this.getMetrics(); return [ `cache_hit_rate{strategy="write_around"} ${metrics.hitRate}`, `cache_miss_rate{strategy="write_around"} ${metrics.missRate}`, `cache_population_latency_ms{strategy="write_around",quantile="0.99"} ${metrics.p99PopulationLatencyMs}`, ]; }}A well-tuned write-around cache should achieve 85-95% hit rates for hot data. If your hit rate is below 80%, investigate: Is TTL too short? Is cache size too small? Are you caching the wrong data?
Write-around's read path implements a fundamental principle: cache what's proven valuable. By populating the cache only through read operations, the system automatically builds a collection of hot data that reflects actual usage patterns.
What's Next:
Understanding how the cache is populated is only half the story. What happens when a read request hits an empty cache? The next page dives deep into read miss behavior—analyzing the latency profile, database load implications, and strategies for minimizing the impact of cache misses.
You now understand the read path of write-around caching—how demand-driven population works, cache miss handling strategies, TTL configuration, optimization techniques, and efficiency metrics. You can implement and tune a write-around cache that efficiently serves read-heavy workloads.