system-design · intermediate
Read-Through vs. Write-Through Cache — Who Updates the Cache?
The Central Question
Consider a high-throughput user profile service on the CacheLab platform (cachelab.com) processing 50,000 requests per second.
- Read queries outnumber write queries by 100 to 1 (50,000 reads/sec vs 500 writes/sec).
- When a user updates their account email address, or when a cache miss occurs for a celebrity user profile, the system must answer two fundamental architectural questions:
- On a Cache Miss: Does the application code query the database and populate the cache explicitly, or does a specialized cache abstraction load the database record transparently?
- On a Write Operation: Does the write synchronously update both the cache and database together, write to the database and invalidate the cache key, or buffer writes in memory to update the database asynchronously in the background?
Selecting the wrong caching pattern leads to severe production defects:
- Failing to synchronize writes causes Stale Cache Read Inconsistencies (users see old profile data indefinitely).
- Failing to suppress concurrent cache misses causes Cache Stampedes (1,000 identical queries hit the primary database simultaneously on cache key expiration).
This lesson answers one central question: How do Read-Through, Write-Through, Write-Behind, and Cache-Aside caching patterns govern data loading, update synchronization, and cache invalidation, and how do engineers prevent cache stampedes using Single-Flight concurrency suppression in high-throughput backend services?
The Four Core Caching Access Patterns
Caching architectures map into four distinct access patterns based on who owns the loading and write-synchronization logic:
flowchart TD
Patterns[Caching Access Patterns] --> ReadPath[Read Loading Paths]
Patterns --> WritePath[Write Synchronization Paths]
ReadPath --> CacheAside[1. Cache-Aside / Lazy Loading<br/>App manages DB & Cache explicitly]
ReadPath --> ReadThrough[2. Read-Through<br/>Cache abstraction loads DB transparently]
WritePath --> WriteThrough[3. Write-Through<br/>Synchronous write to Cache AND DB]
WritePath --> WriteBehind[4. Write-Behind / Write-Back<br/>Async background write to DB]
Figure 1: Taxonomy of the four primary read and write caching patterns.
Architectural Comparison Matrix
| Caching Pattern | Read / Write Responsibility | DB Update Timing | Latency Profile | Best Use Case |
|---|---|---|---|---|
| Cache-Aside (Lazy) | Application code explicitly manages DB and Cache queries. | N/A (App handles writes). | Fast hits; slow initial miss. | General web APIs, microservices with custom invalidation. |
| Read-Through | Cache abstraction transparently queries DB loader on miss. | N/A (Read path only). | Fast hits; loader handles misses. | ORM frameworks, centralized caching proxies. |
| Write-Through | Cache layer synchronously writes to DB before acknowledging. | Synchronous (0 lag). | Higher write latency (Cache + DB write). | Systems requiring zero stale data after writes. |
| Write-Behind (Write-Back) | Cache layer acknowledges write immediately; flushes DB asynchronously. | Asynchronous (Buffered). | Lowest write latency; risk of data loss on crash! | High-write logging, analytics counters, IoT telemetry. |
Read Patterns: Cache-Aside vs. Read-Through
1. Cache-Aside (Lazy Loading)
In **Cache-Aside**, the application code acts as the explicit orchestrator between the database and the cache.sequenceDiagram
autonumber
actor App as Application Code
participant Cache as Redis Cache
participant DB as PostgreSQL Database
App->>Cache: 1. GET user:89041
alt Cache Hit
Cache-->>App: 2a. Return Cached User JSON
else Cache Miss
Cache-->>App: 2b. Return NULL (Key Miss)
App->>DB: 3. SELECT * FROM users WHERE id = 89041
DB-->>App: 4. Return Row Payload
App->>Cache: 5. SET user:89041 Payload (TTL = 300s)
App-->>App: 6. Return Payload to Client
end
Figure 2: Sequence diagram detailing the explicit application flow in Cache-Aside lazy loading.
2. Read-Through with Single-Flight Concurrency Suppression
In **Read-Through**, the application code simply calls `cache.get(key)`. The cache layer itself encapsulates the loader callback to fetch missing records from the database.To prevent Cache Stampedes (where 1,000 concurrent API threads request an expired key simultaneously), production Read-Through loaders use Single-Flight Concurrency Suppression:
sequenceDiagram
autonumber
actor Thread1 as API Thread 1
actor Thread2 as API Thread 2 (Concurrent)
participant Cache as Cache Layer (Read-Through)
participant SF as Single-Flight Mutex
participant DB as PostgreSQL Database
Thread1->>Cache: 1. GET user:89041 (MISS)
Thread2->>Cache: 2. GET user:89041 (MISS)
Cache->>SF: 3. Acquire Single-Flight Lock (user:89041)
Note over SF: Thread 1 acquires Lock.<br/>Thread 2 WAITS on Thread 1's Flight Channel!
SF->>DB: 4. ONLY 1 DB Query: SELECT * FROM users WHERE id = 89041
DB-->>SF: 5. Return Row Payload
SF->>Cache: 6. Populate Cache Key (TTL = 300s)
SF-->>Thread1: 7. Return Payload
SF-->>Thread2: 8. Return Payload (Shares same response!)
Figure 3: Single-Flight concurrency suppression collapsing duplicate DB reads into a single query.
Write Patterns: Write-Through vs. Write-Behind vs. Invalidate-on-Write
1. Write-Through (Synchronous Synchronization)
The application issues a write to the cache abstraction. The cache layer synchronously writes to the primary database **and** updates the cache entry before returning success to the client:$$\text{Write Latency}_{\text{Write-Through}} = \text{Latency}_{\text{Cache Write}} + \text{Latency}_{\text{DB Write}}$$
flowchart LR
App[Application Write] --> Cache[Write-Through Cache Layer]
Cache -->|1. Sync Write| DB[(PostgreSQL Database)]
Cache -->|2. Sync Update| Memory[Update RAM Key]
Memory --> Ack[Return Success ACK to Client]
Figure 4: Write-Through synchronous dual-write pattern.
2. Write-Behind / Write-Back (Asynchronous Buffering)
The cache layer acknowledges writes immediately in memory and pushes updates into an asynchronous queue to flush to the database in batches:flowchart LR
App[Application Write] --> Cache[Memory Cache]
Cache -->|1. Immediate ACK| App
Cache -->|2. Async Batch Queue| Queue[Buffer Queue]
Queue -->|3. Background Flush| DB[(PostgreSQL Database)]
style Queue fill:#fff3cd,stroke:#ffc107
Figure 5: Write-Behind asynchronous batch flushing to database storage.
3. Invalidation-on-Write (The Industry Standard Hybrid)
In production microservices, engineers rarely use full Write-Through due to complex dual-store transactional failures.Instead, the standard industry pattern is Invalidate-on-Write (Cache-Aside + Invalidation):
sequenceDiagram
autonumber
actor App as Application Code
participant DB as Primary Database
participant Cache as Redis Cache
App->>DB: 1. UPDATE users SET email = 'new@lab.com' WHERE id = 89041
Note over DB: Database transaction commits successfully!
App->>Cache: 2. DEL user:89041 (Invalidate Key)
Note over Cache: Key is deleted!<br/>Next read automatically triggers a fresh Read-Through reload.
App-->>App: 3. Return Success ACK
Figure 6: Invalidate-on-Write sequence deleting cache keys to ensure fresh subsequent reloads.
Mitigating Cache Stampedes: Probabilistic Early Expiration (XFetch)
While Single-Flight locks suppress duplicate database queries within a single application process, they do not prevent cache stampedes across a distributed cluster of 500 independent application pods. If a hot key expires in Redis, worker pods in all 500 containers will simultaneously miss the cache and attempt to acquire Single-Flight locks.
To eliminate stampedes across distributed node pools, production systems deploy Probabilistic Early Expiration (XFetch).
The XFetch Algorithm Formula
Rather than waiting for a key to expire at its exact TTL boundary ($T_{\text{expiry}}$), XFetch probabilistically recalculates whether to refresh the key early on every read based on computation cost and access frequency:$$\text{Should Refresh?} \iff \text{Time}_{\text{current}} - (\beta \times \delta \times \ln(\text{Random}(0, 1))) > T_{\text{expiry}}$$
Where:
- $\delta$ (delta) is the duration required to compute the database query payload (e.g. $45\text{ms}$).
- $\beta$ (beta) is a aggressiveness constant ($> 0$, default $1.0$).
- $\text{Random}(0, 1)$ generates a uniform random float between $0$ and $1$.
Why XFetch Prevents Stampedes
As a key nears its expiration time ($T_{\text{expiry}}$), the probability of triggering an early refresh gradually increases from $0\%$ to $100\%$. The first single client request that probabilistically succeeds triggers an asynchronous background database reload. The key is refreshed in Redis before its actual TTL expires. Thus, the key never hits an expired state, completely insulating the primary database from thundering herd spikes. Probabilistic early refresh maintains high availability across global cluster nodes.
Complete Worked Example: Go Single-Flight Read-Through Cache Loader
Let's inspect a production Go implementation for the CacheLab platform (cachelab.com) that implements a Read-Through cache manager with Single-Flight concurrency suppression.
package main
import (
"context"
"database/sql"
"fmt"
"sync"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type SingleFlightReadThrough struct {
db sql.DB
redis redis.Client
requestGroup singleflight.Group
ttl time.Duration
}
func NewReadThrough(db sql.DB, rdb redis.Client, ttl time.Duration) *SingleFlightReadThrough {
return &SingleFlightReadThrough{
db: db,
redis: rdb,
ttl: ttl,
}
}
func (r *SingleFlightReadThrough) GetUserReadThrough(ctx context.Context, userID string) (string, error) {
cacheKey := "user:profile:" + userID
// 1. Check Redis RAM Cache
val, err := r.redis.Get(ctx, cacheKey).Result()
if err == nil {
fmt.Printf("[CACHE HIT] Returning %s from RAM\n", cacheKey)
return val, nil
}
// 2. Cache Miss: Execute Single-Flight Suppressor
// If 1,000 goroutines call this simultaneously for the SAME userID,
// singleflight executes the enclosed function EXACTLY ONCE!
v, err, shared := r.requestGroup.Do(cacheKey, func() (interface{}, error) {
fmt.Printf("[SINGLE-FLIGHT EXECUTING] Only 1 DB query dispatched for %s...\n", cacheKey)
var email string
query := "SELECT email FROM users WHERE id = $1"
err := r.db.QueryRowContext(ctx, query, userID).Scan(&email)
if err != nil {
return "", err
}
// Populate Redis Cache
r.redis.Set(ctx, cacheKey, email, r.ttl)
return email, nil
})
if err != nil {
return "", err
}
if shared {
fmt.Printf("[SINGLE-FLIGHT SHARED] Shared single DB query result across callers for %s!\n", cacheKey)
}
return v.(string), nil
}
Failure Modes and Engineering Mitigations
| Failure Scenario | Root Cause | System Symptom | Detection Metric | Mitigation Strategy |
|---|---|---|---|---|
| 1. Cache Stampede (Dogpiling) | A popular key expires; 5,000 concurrent threads execute database queries simultaneously. | Database CPU spikes to 100%; HTTP 504 gateway timeouts across API. | Sudden spike in database active connection pool metrics. | Implement Single-Flight Concurrency Suppression or Probabilistic Early Expiration (XFetch). |
| 2. Write-Behind Data Loss | Write-Behind cache instance suffers hardware crash while holding 50,000 un-flushed writes in RAM. | Committed user data vanishes permanently from the platform. | Data mismatch between cache write ACK count and database inserted row count. | Use persistent write queues (Kafka / Redis Streams) or restrict Write-Behind to non-critical metrics. |
| 3. Dual-Write Split Brain | Write-Through updates database successfully, but Redis cache update fails due to network glitch. | Cache retains stale value indefinitely; readers see old data while DB has new data. | High error count on cache mutation operations. | Use Invalidate-on-Write (DEL key) or asynchronous CDC log invalidation (Debezium). |
| 4. Negative Caching Poisoning | Database error on missing record is cached as a valid NULL result with a long TTL. | Newly created user account receives "User Not Found" errors for 1 hour. | Unexpected high volume of cached NULL key hits. | Apply short TTLs (e.g. 5 seconds) to negative NULL cache entries. |
What You Should Remember
- Cache-Aside leaves control to app code: Application code manages DB reads, cache hits, misses, and population.
- Read-Through encapsulates loading: A loader abstraction transparently queries the database on misses.
- Single-Flight stops stampedes: Single-flight locks collapse concurrent cache misses for the same key into a single database query.
- Write-Behind trades durability for speed: Writing to memory and flushing asynchronously to DB delivers sub-millisecond writes but risks data loss on crashes.
- Invalidation is safer than Write-Through: Deleting cache keys on writes (
DEL key) avoids complex dual-write transaction bugs and ensures fresh subsequent reloads.
Glossary of Terms
| Term | Definition |
|---|---|
| Cache-Aside (Lazy Loading) | A pattern where application code explicitly manages querying and populating both the cache and database. |
| Read-Through | A pattern where the cache layer transparently loads missing records from the database on a cache miss. |
| Write-Through | A pattern where writes synchronously update both the cache layer and database before returning success. |
| Write-Behind (Write-Back) | A pattern where writes acknowledge immediately in cache memory and flush asynchronously to the database. |
| Single-Flight | A concurrency pattern that suppresses duplicate concurrent executions, sharing one execution result among all callers. |
| Cache Stampede | The collapse in performance caused when concurrent requests hit an expired cache key simultaneously and overload the database. |
Practice Scenario and Self-Assessment
Architecture Scenario
You are designing the caching strategy for an online gaming leaderboard (`gameverse.com`). The system processes:- 100,000 score submission updates per second from active players.
- 1,000,000 leaderboard view queries per second from spectators.
- Evaluate whether Write-Through or Write-Behind is better suited for score submission updates, taking write throughput and latency into account.
- Formulate a Single-Flight Read-Through strategy to serve spectator leaderboard views without collapsing the database.
Interactive Self-Assessment
Deleting the key avoids dual-write race conditions and ensures subsequent reads fetch fresh, authoritative database records.
Deleting a cache key takes 10 seconds to execute in Redis.
Invalidating cache keys is forbidden by PostgreSQL transaction isolation levels.
Write-Through automatically disables database index creation.
It suppresses duplicate concurrent database queries for the same expired key, sharing 1 database query result across all waiting threads.
It encrypts user passwords using AES-256 before writing to disk.
It re-routes external DNS resolution queries to backup TLD servers.
It forces the Java virtual machine to run full garbage collection cycles.
What to Learn Next
- Cache Eviction Policies — LRU, LFU, and FIFO: Explore memory eviction algorithms under RAM capacity limits.
- Distributed Caching — Sharding and High-Availability Clusters: Learn how to scale cache clusters across multiple nodes.
- Caching Strategies — Aside, Through, Behind, and Refresh-Ahead: Revisit write patterns for maintaining cache coherence.
Track: Data, Storage and Messaging
Previous: Distributed Caching — Sharding and High-Availability Clusters
Next: Stale Cache After Write — When Your Own Update Disappears
By Shubham Jain