Loading learning content...
In distributed systems, caching is both a performance multiplier and a source of profound complexity. The moment you introduce a cache between your application and database, you face a fundamental question: How do you keep data in the cache synchronized with the source of truth?
This isn't merely an academic concern. Production systems have suffered catastrophic failures, lost transactions, and corrupted data because of poorly managed cache consistency. E-commerce platforms have sold items that didn't exist. Financial systems have processed duplicate transactions. Social platforms have shown users data they weren't authorized to see—all because cache and database fell out of sync.
Write-through caching represents one of the most elegant solutions to this problem. It provides a systematic approach that prioritizes consistency above all else, making it indispensable for systems where data integrity is non-negotiable.
By the end of this page, you will understand the precise mechanics of write-through caching—from the synchronous write protocol to the internal state transitions. You'll be able to trace a write operation through every component, understand why each step exists, and recognize the architectural implications of this pattern.
Write-through caching operates on a deceptively simple principle: every write operation must update both the cache and the underlying database synchronously, and the write is only considered complete when both operations succeed.
This synchronous dual-write protocol creates a strong consistency guarantee. At no point can the cache contain data that doesn't also exist in the database (assuming no cache eviction). The database and cache move forward together, lockstep, as a unified system.
The Write-Through Protocol:
This sequence is crucial. The database write happens first, and the cache update happens second. If the database write fails, the cache is never updated, maintaining consistency.
Database before cache is intentional. If we updated the cache first and the database write failed, we'd have inconsistent data. By writing to the authoritative source (database) first, we ensure the cache only reflects confirmed, durable data.
Visualizing the Write Path:
Consider a user updating their profile name from "John" to "Jonathan":
Time T0: Application → Cache Layer: UPDATE user SET name='Jonathan' WHERE id=42
Time T1: Cache Layer → Database: UPDATE user SET name='Jonathan' WHERE id=42
Time T2: Database → Cache Layer: ACK (success, 1 row affected)
Time T3: Cache Layer: SET cache[user:42] = {id:42, name:'Jonathan', ...}
Time T4: Cache Layer → Application: ACK (success)
The total latency is T4 - T0, which includes:
This latency is the trade-off for consistency—a theme we'll explore deeply.
Understanding write-through caching requires examining the architectural components and their interactions. The pattern can be implemented at different layers, each with distinct characteristics.
| Implementation Layer | Description | Examples | Characteristics |
|---|---|---|---|
| Application Layer | Cache logic embedded in application code | Custom ORM hooks, service layer interceptors | Full control, but scattered logic across codebase |
| Caching Middleware | Transparent proxy between app and database | Redis as write-through proxy, custom middleware | Centralized, but adds network hop |
| Database Proxy | Specialized proxy handling cache population | ProxySQL with caching, Vitess | Transparent to application, complex setup |
| ORM/Framework Level | Built into data access framework | Hibernate second-level cache, Entity Framework | Easy integration, framework-dependent |
Application-Layer Implementation:
This is the most common approach, where the application explicitly manages both cache and database operations. Here's a conceptual implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
class WriteThroughCacheService: """ Write-through cache implementation with synchronous dual writes. Guarantees: - Data in cache is always in database (no orphaned cache entries) - Application confirms only after both writes succeed - Read-after-write consistency for the writing client """ def __init__(self, cache: CacheClient, database: DatabaseClient): self.cache = cache self.database = database self.metrics = MetricsCollector() def write(self, key: str, value: Any, ttl: Optional[int] = None) -> WriteResult: """ Perform write-through operation. Steps: 1. Write to database (authoritative source) 2. On success, write to cache 3. Return result to caller Failure Modes: - Database write fails: Cache untouched, operation fails - Cache write fails after DB success: Log error, return success (cache will be populated on next read - accept temporary inconsistency) """ write_start = time.monotonic() try: # Step 1: Database write (blocking) db_result = self.database.write(key, value) db_latency = time.monotonic() - write_start self.metrics.record('db_write_latency', db_latency) if not db_result.success: self.metrics.increment('write_through_db_failures') return WriteResult(success=False, error=db_result.error) # Step 2: Cache update (after DB success) cache_start = time.monotonic() try: self.cache.set(key, value, ttl=ttl) cache_latency = time.monotonic() - cache_start self.metrics.record('cache_write_latency', cache_latency) except CacheException as e: # Cache write failed, but DB succeeded # Log and continue - cache will be populated on read self.metrics.increment('write_through_cache_failures') logger.error(f"Cache write failed for key {key}: {e}") # Note: We still return success because the authoritative write succeeded total_latency = time.monotonic() - write_start self.metrics.record('write_through_total_latency', total_latency) return WriteResult(success=True, version=db_result.version) except DatabaseException as e: self.metrics.increment('write_through_failures') raise WriteThroughException(f"Database write failed: {e}") def read(self, key: str) -> Optional[Any]: """ Read with cache-aside pattern for cache misses. Write-through only handles writes; reads typically use cache-aside: 1. Check cache 2. On miss, read from database 3. Populate cache with read value """ # Try cache first cached = self.cache.get(key) if cached is not None: self.metrics.increment('cache_hits') return cached # Cache miss - read from database self.metrics.increment('cache_misses') db_value = self.database.read(key) if db_value is not None: # Populate cache for future reads try: self.cache.set(key, db_value) except CacheException: pass # Read still succeeds return db_valueNotice the asymmetric error handling: database failures abort the operation, but cache failures are logged and tolerated. This reflects the principle that the database is the source of truth. A cache miss is recoverable; a database inconsistency is not.
To truly understand write-through caching, we must analyze the possible states of the cache-database system and the transitions between them. This formal analysis reveals subtle edge cases that informal reasoning might miss.
System State Space:
Let's define the possible states for a single key:
Valid State Transitions in Write-Through:
| Operation | From State | To State | Notes |
|---|---|---|---|
| Write (new key) | A (Empty) | C (Synchronized) | Create in DB then cache |
| Write (existing key) | C (Synchronized) | C (Synchronized) | Update both synchronously |
| Write (cache miss) | B (DB-Only) | C (Synchronized) | Update DB, populate cache |
| Delete | C (Synchronized) | A (Empty) | Remove from both |
| Cache eviction | C (Synchronized) | B (DB-Only) | Cache removed, DB intact |
| Read (cache miss) | B (DB-Only) | C (Synchronized) | Cache-aside read pattern |
The Invalid States:
State D (Inconsistent) should never occur in a correctly implemented write-through system. If it does, it indicates:
State E (Cache-Only) represents orphaned data—values in the cache that don't exist in the database. In write-through, this is prevented by always writing to the database first. However, it can occur if:
Write-through consistency guarantees only hold when ALL writes go through the caching layer. If any process writes directly to the database—a migration script, an admin console, a different service—the cache becomes stale. This is a fundamental constraint of application-level caching.
The defining characteristic of write-through caching is its synchronous nature. Understanding what "synchronous" means in this context—and its implications—is essential for proper implementation.
Definition of Synchronous Write:
A synchronous write means the calling thread blocks until the operation completes. In write-through:
This is in contrast to asynchronous patterns (like write-back) where the caller receives an immediate response and the actual write happens later.
Latency Anatomy:
Let's break down the components of write-through latency:
┌─────────────────────────────────────────────────────────────────┐
│ WRITE-THROUGH LATENCY │
├──────────┬──────────┬──────────┬──────────┬──────────┬─────────┤
│ App → │ Cache │ Network │ Database │ Cache │ → App │
│ Cache │ Process │ to DB │ Write │ Write │ Return │
├──────────┼──────────┼──────────┼──────────┼──────────┼─────────┤
│ ~1ms │ ~1ms │ 1-5ms │ 2-20ms │ ~1ms │ ~1ms │
└──────────┴──────────┴──────────┴──────────┴──────────┴─────────┘
Total: 6-29ms per write (typical)
In high-performance systems, this latency is often acceptable for writes while reads benefit from near-instant cache hits. The key insight is that write-through optimizes for read performance and consistency at the cost of write performance.
Distributed systems fail. Understanding how write-through caching behaves under failure is critical for building resilient systems. Let's examine each failure scenario.
Scenario 1: Database Write Failure
The most common failure mode. The database rejects the write due to:
Behavior: The cache is never updated. The operation returns an error to the caller. The system remains consistent because no change was made anywhere.
Recovery: None needed—the system self-heals by not making partial changes.
Scenario 2: Cache Write Failure (After DB Success)
The database write succeeds, but the cache update fails:
Behavior: This is the tricky case. The database has the new value, but the cache doesn't. Depending on implementation:
Option A: Return failure (strict)
- Caller sees failure
- Must retry or implement compensating transaction
- Cache may have stale data
Option B: Return success (pragmatic)
- Caller sees success (database write was the important part)
- Log cache failure for monitoring
- Cache will be corrected on next read (cache-aside)
- Accept temporary staleness
Recovery for Option A:
Recovery for Option B:
Scenario 3: Application Crash Mid-Write
The application crashes after the database write but before confirming to the user.
Behavior: The write is actually complete (in the database), but:
Recovery:
The most robust write-through implementations use pragmatic error handling (Option B above) combined with comprehensive monitoring. Track cache write failures as a key metric. If they exceed a threshold, investigate—but don't block writes on cache failures, as this reduces availability without improving consistency (the database already has the truth).
Real-world write-through implementations vary based on requirements. Here are the primary patterns and when to use each.
Pattern 1: Inline Write-Through
The simplest pattern where write-through logic is embedded directly in business logic:
12345678910111213
// Inline write-through - simple but creates code duplicationasync function updateUserProfile(userId: string, updates: ProfileUpdates) { // 1. Write to database const result = await db.users.update({ where: { id: userId }, data: updates, }); // 2. Update cache immediately after DB success await cache.set(`user:${userId}`, result, { ttl: 3600 }); return result;}Pattern 2: Repository Pattern with Write-Through
Encapsulates caching logic in a repository layer, providing consistent behavior across all data access:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
class WriteThroughRepository<T extends Entity> { constructor( private db: DatabaseConnection, private cache: CacheClient, private entityName: string, private ttl: number = 3600 ) {} async save(entity: T): Promise<T> { // DB write first const saved = await this.db.save(entity); // Cache on success const cacheKey = this.buildKey(saved.id); await this.cache.set(cacheKey, saved, { ttl: this.ttl }); return saved; } async findById(id: string): Promise<T | null> { const cacheKey = this.buildKey(id); // Try cache first (cache-aside read) const cached = await this.cache.get<T>(cacheKey); if (cached) return cached; // Miss: load from DB and cache const entity = await this.db.findById(id); if (entity) { await this.cache.set(cacheKey, entity, { ttl: this.ttl }); } return entity; } async delete(id: string): Promise<void> { // Delete from DB first await this.db.delete(id); // Remove from cache await this.cache.delete(this.buildKey(id)); } private buildKey(id: string): string { return `${this.entityName}:${id}`; }} // Usageconst userRepo = new WriteThroughRepository<User>(db, cache, 'user');await userRepo.save(user); // Automatically write-through cachedPattern 3: Decorator/Interceptor Pattern
Uses aspect-oriented programming to add write-through behavior transparently:
1234567891011121314151617181920212223242526272829303132
# Python decorator for write-through cachingdef write_through_cached(cache_key_func, ttl=3600): """ Decorator that adds write-through caching to a write operation. Args: cache_key_func: Function that derives cache key from arguments ttl: Cache entry time-to-live in seconds """ def decorator(func): @functools.wraps(func) async def wrapper(*args, **kwargs): # Execute the original function (database write) result = await func(*args, **kwargs) # Cache the result on success cache_key = cache_key_func(*args, **kwargs) await cache.set(cache_key, result, ttl=ttl) return result return wrapper return decorator # Usageclass UserService: @write_through_cached( cache_key_func=lambda self, user_id, data: f"user:{user_id}", ttl=3600 ) async def update_user(self, user_id: str, data: dict) -> User: # This only contains DB logic; caching is handled by decorator return await self.db.users.update(user_id, data)We've covered the fundamental mechanics of write-through caching. Let's consolidate the key concepts:
What's Next:
Now that you understand the mechanics of write-through caching, we'll explore its most important characteristic: how the synchronous dual-write approach guarantees that data is written to cache and database together. This deep dive into the consistency model will prepare you to make informed decisions about when write-through is the right choice.
You now understand the foundational mechanics of write-through caching—the synchronous write protocol, state transitions, failure modes, and implementation patterns. Next, we'll examine exactly how cache and database coordination works in practice.