Loading learning content...
What if your application could write at memory speed while still ensuring data eventually reaches the database? What if you could acknowledge millions of writes per second without waiting for disk I/O?
The write-behind cache pattern (also known as write-back) delivers exactly this. Writes are accepted into the cache and acknowledged immediately. The cache then asynchronously persists data to the underlying store, batching and optimizing writes in the background.
This pattern powers some of the highest-throughput systems in the world—from in-memory databases to CPU caches to distributed storage systems. But with great performance comes great responsibility: asynchronous writes introduce complexity around durability, ordering, and failure recovery.
By the end of this page, you will understand the write-behind pattern thoroughly—its asynchronous nature, the durability-performance tradeoff, write coalescing, failure handling, and precisely when its benefits outweigh its risks.
Write-behind fundamentally decouples write acknowledgment from persistence. When the application writes data, the cache:
The key insight: the application is done before the database knows anything changed.
This is the inverse of write-through, where acknowledgment waits for persistence. Write-behind trades immediate durability for dramatically lower write latency.
Write-behind is not appropriate for all data. If the cache crashes before flushing to the database, pending writes are lost. For financial transactions, critical user data, or anything where data loss is unacceptable, use write-through or direct database writes.
Write-behind involves two distinct paths: the synchronous write path (application to cache) and the asynchronous persistence path (cache to database).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
/** * Write-Behind Cache Implementation * * Writes are acknowledged immediately; persistence happens asynchronously. */interface CacheEntry<V> { value: V; dirty: boolean; lastModified: number; writeCount: number; // For metrics: how many writes coalesced} interface WriteBehindCache<K, V> { get(key: K): Promise<V | null>; set(key: K, value: V): void; // Note: synchronous, non-blocking flush(): Promise<void>; // Force immediate flush close(): Promise<void>; // Graceful shutdown} type DatabaseWriter<K, V> = { writeBatch(entries: Array<[K, V]>): Promise<void>;}; class WriteBehindCacheImpl<K extends string, V> implements WriteBehindCache<K, V> { private cache: Map<K, CacheEntry<V>>; private database: DatabaseWriter<K, V>; private flushIntervalMs: number; private maxDirtyEntries: number; private flushTimer: NodeJS.Timeout | null; private flushing: boolean; private shutdownRequested: boolean; constructor( database: DatabaseWriter<K, V>, options: { flushIntervalMs: number; maxDirtyEntries: number } ) { this.cache = new Map(); this.database = database; this.flushIntervalMs = options.flushIntervalMs; this.maxDirtyEntries = options.maxDirtyEntries; this.flushTimer = null; this.flushing = false; this.shutdownRequested = false; // Start background flush process this.scheduleFlush(); } async get(key: K): Promise<V | null> { const entry = this.cache.get(key); return entry?.value ?? null; } set(key: K, value: V): void { // Fast path: update memory and return immediately const existing = this.cache.get(key); this.cache.set(key, { value, dirty: true, lastModified: Date.now(), writeCount: (existing?.writeCount ?? 0) + 1 }); // Check if we need immediate flush due to too many dirty entries if (this.getDirtyCount() >= this.maxDirtyEntries) { this.triggerFlush(); } } private getDirtyCount(): number { let count = 0; for (const entry of this.cache.values()) { if (entry.dirty) count++; } return count; } private scheduleFlush(): void { if (this.shutdownRequested) return; this.flushTimer = setTimeout(() => { this.triggerFlush(); }, this.flushIntervalMs); } private triggerFlush(): void { if (this.flushing) return; // Already flushing this.flushing = true; this.executeFlush() .catch(err => console.error('Flush failed:', err)) .finally(() => { this.flushing = false; if (!this.shutdownRequested) { this.scheduleFlush(); } }); } private async executeFlush(): Promise<void> { // Collect dirty entries const dirtyEntries: Array<[K, V]> = []; const dirtyKeys: K[] = []; for (const [key, entry] of this.cache.entries()) { if (entry.dirty) { dirtyEntries.push([key, entry.value]); dirtyKeys.push(key); } } if (dirtyEntries.length === 0) { return; // Nothing to flush } console.log(`Flushing ${dirtyEntries.length} dirty entries to database`); // Persist to database await this.database.writeBatch(dirtyEntries); // Mark entries as clean for (const key of dirtyKeys) { const entry = this.cache.get(key); if (entry) { entry.dirty = false; entry.writeCount = 0; } } } async flush(): Promise<void> { // Force immediate flush (useful for testing or graceful shutdown) await this.executeFlush(); } async close(): Promise<void> { // Graceful shutdown: stop scheduling and flush pending writes this.shutdownRequested = true; if (this.flushTimer) { clearTimeout(this.flushTimer); } // Final flush to ensure no data loss await this.flush(); console.log('Write-behind cache closed gracefully'); }}Critical observations:
set() is synchronous and returns void — There's no await, no promise. The write is "done" the moment memory is updated.
Dirty tracking is essential — We must know which entries need persistence. This metadata adds memory overhead.
Write coalescing happens naturally — If key X is written 100 times before a flush, only the final value is persisted. The writeCount tracks this for metrics.
Graceful shutdown is critical — On process termination, pending dirty entries must be flushed to avoid data loss.
One of write-behind's most powerful features is write coalescing—merging multiple writes to the same key into a single database write. This provides significant performance benefits for certain workloads.
Example: User Online Status
Imagine tracking user online status that updates every 30 seconds:
[0:00] User comes online → cache: set('status:123', 'online')
[0:30] Heartbeat received → cache: set('status:123', 'online')
[1:00] Heartbeat received → cache: set('status:123', 'online')
[1:30] Heartbeat received → cache: set('status:123', 'online')
[2:00] Flush interval → database: 1 write (not 4!)
Without write coalescing: 4 database writes per 2 minutes per user With write coalescing: 1 database write per 2 minutes per user
For 1 million active users, this is the difference between 2 million writes and 500,000 writes per minute.
| Workload Pattern | Coalescing Benefit | Example |
|---|---|---|
| High-frequency updates to same keys | Massive—O(1) writes instead of O(n) | Real-time counters, status updates |
| Burst writes then quiet | Significant—burst absorbed by cache | User editing sessions, batch imports |
| Uniform writes to unique keys | Minimal—little overlap to coalesce | Event logging, new record creation |
| Incremental aggregation | Extreme—only final aggregate persisted | Page view counts, running sums |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
/** * Write-Behind Cache with Coalescing Metrics * * Tracks how many writes were coalesced to demonstrate efficiency. */class MeasuredWriteBehindCache<K extends string, V> { private cache: Map<K, CacheEntry<V>>; private metrics: { totalWrites: number; coalescedWrites: number; dbWrites: number; }; constructor(database: DatabaseWriter<K, V>) { this.cache = new Map(); this.metrics = { totalWrites: 0, coalescedWrites: 0, dbWrites: 0 }; } set(key: K, value: V): void { this.metrics.totalWrites++; const existing = this.cache.get(key); if (existing?.dirty) { // This write will overwrite a pending write = coalesced this.metrics.coalescedWrites++; } this.cache.set(key, { value, dirty: true, lastModified: Date.now(), writeCount: (existing?.writeCount ?? 0) + 1 }); } private async executeFlush(): Promise<void> { const dirtyEntries: Array<[K, V]> = []; for (const [key, entry] of this.cache.entries()) { if (entry.dirty) { dirtyEntries.push([key, entry.value]); } } if (dirtyEntries.length > 0) { await this.database.writeBatch(dirtyEntries); this.metrics.dbWrites += dirtyEntries.length; // Mark clean for (const [key] of dirtyEntries) { const entry = this.cache.get(key); if (entry) entry.dirty = false; } } } getCoalescingEfficiency(): number { // Ratio of avoided writes // 1.0 = no benefit (every write hit DB) // Higher = more coalescing if (this.metrics.dbWrites === 0) return 0; return this.metrics.totalWrites / this.metrics.dbWrites; } getStats(): string { const efficiency = this.getCoalescingEfficiency(); return ` Total application writes: ${this.metrics.totalWrites} Writes coalesced (overwritten before flush): ${this.metrics.coalescedWrites} Actual database writes: ${this.metrics.dbWrites} Coalescing efficiency: ${efficiency.toFixed(2)}x Database writes saved: ${this.metrics.totalWrites - this.metrics.dbWrites} `; }} // Example output after heavy workload:// Total application writes: 1,000,000// Writes coalesced: 847,000// Actual database writes: 153,000// Coalescing efficiency: 6.54x// Database writes saved: 847,000Track your coalescing ratio in production. If it's close to 1:1 (every application write becomes a database write), you're not benefiting from write-behind's coalescing. Either your workload doesn't suit it, or your flush interval is too short.
Write-behind's Achilles' heel is durability. Between the moment a write is acknowledged and when it's flushed to the database, data exists only in volatile memory. This creates several failure scenarios that must be understood and mitigated.
| Failure Type | What Happens | Data Loss Risk | Mitigation |
|---|---|---|---|
| Cache process crash | Dirty entries lost | High—all pending writes lost | WAL, replicated cache, short flush interval |
| Machine failure | RAM contents lost | High—all pending writes lost | Distributed cache, synchronous replication |
| Database unavailable | Flushes fail, dirty entries accumulate | Medium—memory pressure, eventual OOM | Bounded queue, back-pressure to application |
| Network partition | Cache and DB diverge | Low—data in cache safe until flush | Retry queue, reconciliation on recovery |
| Flush timeout | Some entries may or may not persist | Medium—unknown state | Idempotent writes, status tracking |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
/** * Write-Behind Cache with Durability Enhancements * * Uses a Write-Ahead Log (WAL) to survive process crashes. */interface WAL<K, V> { append(key: K, value: V): Promise<void>; getUnflushed(): Promise<Array<[K, V]>>; markFlushed(keys: K[]): Promise<void>; close(): Promise<void>;} class DurableWriteBehindCache<K extends string, V> { private cache: Map<K, CacheEntry<V>>; private wal: WAL<K, V>; private database: DatabaseWriter<K, V>; constructor( database: DatabaseWriter<K, V>, wal: WAL<K, V> ) { this.cache = new Map(); this.database = database; this.wal = wal; } /** * Recovery on startup: replay unflushed WAL entries */ async recover(): Promise<void> { console.log('Recovering from WAL...'); const unflushed = await this.wal.getUnflushed(); if (unflushed.length > 0) { console.log(`Found ${unflushed.length} unflushed entries, replaying...`); // Restore to cache as dirty for (const [key, value] of unflushed) { this.cache.set(key, { value, dirty: true, lastModified: Date.now(), writeCount: 1 }); } // Immediately flush to database await this.flush(); } console.log('Recovery complete'); } async set(key: K, value: V): Promise<void> { // CRITICAL: Write to WAL BEFORE updating cache // If we crash after WAL but before cache, recovery replays it await this.wal.append(key, value); // Now safe to update cache this.cache.set(key, { value, dirty: true, lastModified: Date.now(), writeCount: 1 }); } private async executeFlush(): Promise<void> { const dirtyEntries: Array<[K, V]> = []; const dirtyKeys: K[] = []; for (const [key, entry] of this.cache.entries()) { if (entry.dirty) { dirtyEntries.push([key, entry.value]); dirtyKeys.push(key); } } if (dirtyEntries.length === 0) return; // Persist to database await this.database.writeBatch(dirtyEntries); // Mark as clean in cache for (const key of dirtyKeys) { const entry = this.cache.get(key); if (entry) entry.dirty = false; } // CRITICAL: Only mark WAL as flushed AFTER database success // This ensures crash recovery doesn't skip failed writes await this.wal.markFlushed(dirtyKeys); }} /** * Simple File-Based WAL Implementation */class FileWAL<K extends string, V> implements WAL<K, V> { private filePath: string; private entries: Map<K, { value: V; flushed: boolean }>; constructor(filePath: string) { this.filePath = filePath; this.entries = new Map(); } async load(): Promise<void> { // Load existing WAL from disk on startup try { const data = await fs.readFile(this.filePath, 'utf-8'); this.entries = new Map(JSON.parse(data)); } catch { // File doesn't exist or is corrupted - start fresh this.entries = new Map(); } } async append(key: K, value: V): Promise<void> { this.entries.set(key, { value, flushed: false }); await this.persist(); } async getUnflushed(): Promise<Array<[K, V]>> { const unflushed: Array<[K, V]> = []; for (const [key, entry] of this.entries) { if (!entry.flushed) { unflushed.push([key, entry.value]); } } return unflushed; } async markFlushed(keys: K[]): Promise<void> { for (const key of keys) { const entry = this.entries.get(key); if (entry) { entry.flushed = true; } } // Compact: remove flushed entries for (const [key, entry] of this.entries) { if (entry.flushed) { this.entries.delete(key); } } await this.persist(); } private async persist(): Promise<void> { const data = JSON.stringify(Array.from(this.entries)); await fs.writeFile(this.filePath, data); } async close(): Promise<void> { await this.persist(); }}Adding a WAL for durability means every write now involves disk I/O (to the WAL). This reduces the performance advantage of write-behind. However, sequential WAL writes are much faster than random database writes, so significant benefits remain. Evaluate whether your durability requirements justify the complexity.
The flush strategy determines when dirty entries are persisted to the database. This is a critical tuning parameter that balances latency, throughput, and durability.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
/** * Configurable Flush Strategy Interface */interface FlushStrategy { shouldFlush(stats: FlushStats): boolean; onFlushComplete(stats: FlushStats): void;} interface FlushStats { dirtyCount: number; dirtyBytes: number; oldestDirtyAge: number; // milliseconds since oldest dirty entry was written lastFlushTime: number; // timestamp of last flush lastFlushDuration: number; // how long last flush took consecutiveFailures: number;} /** * Hybrid Time + Count Strategy */class HybridFlushStrategy implements FlushStrategy { constructor( private maxDirtyCount: number, private maxAgeMs: number, private minIntervalMs: number ) {} shouldFlush(stats: FlushStats): boolean { const now = Date.now(); const timeSinceLastFlush = now - stats.lastFlushTime; // Don't flush too frequently if (timeSinceLastFlush < this.minIntervalMs) { return false; } // Flush if too many dirty entries if (stats.dirtyCount >= this.maxDirtyCount) { return true; } // Flush if oldest entry is too old if (stats.oldestDirtyAge >= this.maxAgeMs) { return true; } return false; } onFlushComplete(stats: FlushStats): void { // Could log or adjust parameters here }} /** * Adaptive Strategy that backs off on failures */class AdaptiveFlushStrategy implements FlushStrategy { private baseIntervalMs: number; private currentIntervalMs: number; private maxBackoffMs: number; constructor(baseIntervalMs: number, maxBackoffMs: number) { this.baseIntervalMs = baseIntervalMs; this.currentIntervalMs = baseIntervalMs; this.maxBackoffMs = maxBackoffMs; } shouldFlush(stats: FlushStats): boolean { const now = Date.now(); const timeSinceLastFlush = now - stats.lastFlushTime; return timeSinceLastFlush >= this.currentIntervalMs; } onFlushComplete(stats: FlushStats): void { if (stats.consecutiveFailures > 0) { // Back off on failures (exponential backoff) this.currentIntervalMs = Math.min( this.currentIntervalMs * 2, this.maxBackoffMs ); console.log(`Flush failed, backing off to ${this.currentIntervalMs}ms`); } else { // Reset to base on success if (this.currentIntervalMs > this.baseIntervalMs) { console.log(`Flush succeeded, resetting to base interval`); this.currentIntervalMs = this.baseIntervalMs; } } }} /** * Priority-based strategy that prioritizes important data */class PriorityFlushStrategy implements FlushStrategy { private priorityThresholdMs: number; // High priority if older than this shouldFlush(stats: FlushStats): boolean { // Always flush if we have high-priority (old) entries if (stats.oldestDirtyAge > this.priorityThresholdMs) { return true; } // Regular flush based on count return stats.dirtyCount >= 1000; }} // Usage in cacheclass WriteBehindCacheWithStrategy<K extends string, V> { private flushStrategy: FlushStrategy; private stats: FlushStats; constructor(strategy: FlushStrategy) { this.flushStrategy = strategy; this.stats = this.initializeStats(); } private checkFlush(): void { if (this.flushStrategy.shouldFlush(this.stats)) { this.triggerFlush(); } }}| Strategy | Latency to DB | Coalescing | Complexity | Best For |
|---|---|---|---|---|
| Time-based (1s) | ~1 second | Low | Simple | Low-volume, latency-tolerant |
| Time-based (60s) | ~1 minute | High | Simple | High-frequency updates |
| Count-based (1000) | Varies | Medium | Simple | Consistent volume |
| Hybrid | Bounded | Medium-High | Medium | Variable workloads |
| Adaptive | Bounded | Medium-High | High | Unreliable databases |
Write-behind occupies a specific point in the cache pattern design space. Understanding its position relative to other patterns helps in selecting the right approach.
| Aspect | Write-Through | Write-Behind | Cache-Aside (Invalidate) |
|---|---|---|---|
| Write Latency | High (DB sync) | Very Low (memory only) | Medium (DB + cache op) |
| Read-after-Write | Immediate consistency | Immediate consistency | Immediate consistency |
| Database Consistency | Strong | Eventual | Eventual |
| Durability | Immediate | Delayed (risky) | Immediate |
| Write Throughput | DB-limited | Memory-limited | DB-limited |
| Write Coalescing | None | Natural | None |
| Complexity | Medium | High | Low |
| Failure Handling | Simple | Complex | Simple |
| Database Load | Every write | Batched writes | Writes + deletes |
Production systems often use different patterns for different data. Use write-through for critical user data, write-behind for analytics/counters, and cache-aside for configuration. Match the pattern to the data's consistency and durability requirements.
Write-behind caching is ubiquitous in systems that prioritize throughput over immediate durability. Here are notable examples:
sync exists.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
/** * Analytics Event Collector with Write-Behind * * Real example: collecting page view events at high volume */interface AnalyticsEvent { eventType: string; userId: string; timestamp: number; properties: Record<string, unknown>;} class AnalyticsCollector { private buffer: AnalyticsEvent[]; private maxBufferSize: number; private flushIntervalMs: number; private warehouse: DataWarehouse; constructor(warehouse: DataWarehouse) { this.buffer = []; this.maxBufferSize = 10000; this.flushIntervalMs = 5000; // 5 seconds this.warehouse = warehouse; // Start background flusher setInterval(() => this.flush(), this.flushIntervalMs); } /** * Track an event - returns immediately * * Called potentially millions of times per minute */ track(event: AnalyticsEvent): void { this.buffer.push(event); // Flush if buffer is full (back-pressure) if (this.buffer.length >= this.maxBufferSize) { this.flush(); } } private async flush(): Promise<void> { if (this.buffer.length === 0) return; // Swap buffer atomically const toFlush = this.buffer; this.buffer = []; console.log(`Flushing ${toFlush.length} analytics events`); try { // Batch insert to data warehouse await this.warehouse.insertBatch(toFlush); } catch (error) { // Analytics can tolerate some loss // Log and continue rather than blocking console.error(`Analytics flush failed: ${toFlush.length} events lost`, error); // Could implement retry queue for more important events } }} // Usageconst analytics = new AnalyticsCollector(warehouse); // Called on every page view - must be fastanalytics.track({ eventType: 'page_view', userId: 'user123', timestamp: Date.now(), properties: { page: '/home', referrer: 'google.com' }}); // Returns immediately - event is in memory buffer// Will be persisted within 5 seconds (or sooner if buffer fills)The analytics example explicitly accepts that some events may be lost on failure. This is a business decision—losing 0.01% of page view events is acceptable if it enables processing millions of events per second. Not all data has equal value.
The write-behind cache pattern maximizes write throughput by acknowledging writes immediately and persisting asynchronously. It's the performance-first approach to caching, trading durability for speed.
Module Complete:
You've now mastered the four fundamental cache patterns:
These patterns form the foundation of caching strategy in production systems. In practice, you'll combine them: read-through with write-through for consistent caches, cache-aside with write-behind for flexible, high-performance systems. The key is matching patterns to your data's consistency and durability requirements.
Congratulations! You now have comprehensive knowledge of the four core cache patterns. You understand their mechanics, tradeoffs, and appropriate use cases. This knowledge enables you to design caching strategies that meet your system's specific performance, consistency, and durability requirements.