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.


  1. 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?
  2. 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:

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 PatternRead / Write ResponsibilityDB Update TimingLatency ProfileBest 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-ThroughCache abstraction transparently queries DB loader on miss.N/A (Read path only).Fast hits; loader handles misses.ORM frameworks, centralized caching proxies.
Write-ThroughCache 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:


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 ScenarioRoot CauseSystem SymptomDetection MetricMitigation 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 LossWrite-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 BrainWrite-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 PoisoningDatabase 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

  1. Cache-Aside leaves control to app code: Application code manages DB reads, cache hits, misses, and population.
  2. Read-Through encapsulates loading: A loader abstraction transparently queries the database on misses.
  3. Single-Flight stops stampedes: Single-flight locks collapse concurrent cache misses for the same key into a single database query.
  4. Write-Behind trades durability for speed: Writing to memory and flushing asynchronously to DB delivers sub-millisecond writes but risks data loss on crashes.
  5. 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

TermDefinition
Cache-Aside (Lazy Loading)A pattern where application code explicitly manages querying and populating both the cache and database.
Read-ThroughA pattern where the cache layer transparently loads missing records from the database on a cache miss.
Write-ThroughA 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-FlightA concurrency pattern that suppresses duplicate concurrent executions, sharing one execution result among all callers.
Cache StampedeThe 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:
  1. 100,000 score submission updates per second from active players.
  2. 1,000,000 leaderboard view queries per second from spectators.
**Questions**:
  1. Evaluate whether Write-Through or Write-Behind is better suited for score submission updates, taking write throughput and latency into account.
  2. 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

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

All articles · Study paths

Shubham Jain · Learning Lab