4 min read
On this page

Caching Patterns

Overview

Caching patterns define the relationship between your application, the cache, and the underlying data store. Each pattern has different consistency guarantees, performance characteristics, and failure modes. Choosing the right pattern depends on whether your workload is read-heavy or write-heavy, how stale data can be, and how complex you want your application logic.

Cache-Aside (Lazy Loading)

The application manages the cache explicitly. It checks the cache first, and on a miss, fetches from the database and populates the cache.

How It Works

Read path:
  1. Application checks cache for key "user:123"
  2. Cache hit -> Return cached data
  3. Cache miss -> Query database for user 123
  4. Store result in cache with TTL
  5. Return data to caller

Write path:
  1. Application writes to database
  2. Application invalidates (deletes) the cache entry
  3. Next read will miss and re-populate the cache

Pseudocode:
  function getUser(userId):
    cached = cache.get("user:" + userId)
    if cached != null:
      return cached

    user = database.query("SELECT * FROM users WHERE id = ?", userId)
    cache.set("user:" + userId, user, ttl=300)
    return user

  function updateUser(userId, data):
    database.update("UPDATE users SET ... WHERE id = ?", userId, data)
    cache.delete("user:" + userId)

When to Use

  • Read-heavy workloads where most data is read far more than written
  • When you can tolerate brief staleness after writes
  • When you want full control over what gets cached and when
  • General-purpose pattern that works for most use cases

Trade-Offs

Pros:
  - Only frequently accessed data ends up in cache (no wasted memory)
  - Cache failure is not catastrophic (reads fall through to database)
  - Simple to implement and reason about
  - Works with any cache technology

Cons:
  - First request for each item is always a cache miss
  - Potential for stale reads between write and cache invalidation
  - Application code must manage cache logic
  - Cache stampede risk when popular items expire

Race condition:
  Thread A reads from DB (gets old value)
  Thread B updates DB and invalidates cache
  Thread A writes old value to cache
  Result: Cache has stale data until TTL expires

Mitigation: Use cache-aside with short TTLs or versioned keys.

Real-World Usage

Facebook uses cache-aside with Memcached in front of MySQL. The application explicitly manages cache reads and invalidations. Their TAO system extends this pattern with a graph-aware cache.

Read-Through

The cache itself is responsible for loading data from the database on a miss. The application only talks to the cache.

How It Works

Read path:
  1. Application requests "user:123" from cache
  2. Cache hit -> Return cached data
  3. Cache miss -> Cache queries database for user 123
  4. Cache stores the result and returns it to application

The application never directly queries the database for reads.
The cache library or proxy handles the database interaction.

Pseudocode:
  // Cache is configured with a loader function
  cache.configure(loader: function(key):
    return database.query("SELECT * FROM users WHERE id = ?", key.split(":")[1])
  )

  // Application code is simple
  function getUser(userId):
    return cache.get("user:" + userId)  // Cache handles miss internally

When to Use

  • When you want to simplify application code by moving cache logic into infrastructure
  • When the same data loading pattern applies consistently across many access points
  • When using a cache library or proxy that supports read-through natively

Trade-Offs

Pros:
  - Application code is simpler (no cache miss handling)
  - Cache logic is centralized, not scattered across the codebase
  - Consistent cache population behavior

Cons:
  - Cache must understand how to query the data store
  - Less flexibility for complex data loading (joins, aggregations)
  - First request still misses (same as cache-aside)
  - Tighter coupling between cache and database

Used by: Hibernate second-level cache, DynamoDB Accelerator (DAX)

Write-Through

Every write goes through the cache to the database. The cache is always up to date.

How It Works

Write path:
  1. Application writes data to cache
  2. Cache synchronously writes data to database
  3. Write is acknowledged only after both cache and database are updated

Read path:
  1. Application reads from cache
  2. Cache always has the latest data (was updated on write)
  3. Cache miss only happens for data never written through this cache

Pseudocode:
  function updateUser(userId, data):
    // Cache handles both cache update and database write
    cache.put("user:" + userId, data)
    // Internally: cache updates itself AND writes to database
    // Returns only after both succeed

  function getUser(userId):
    return cache.get("user:" + userId)

When to Use

  • When you cannot tolerate any staleness between cache and database
  • When writes are not latency-sensitive (write path is slower due to synchronous database write)
  • Combined with read-through for a fully cache-managed data path

Trade-Offs

Pros:
  - Cache is never stale (write updates cache before acknowledging)
  - Read path is always fast (data is already in cache)
  - No cache invalidation logic needed
  - Data consistency between cache and database

Cons:
  - Write latency increases (must write to both cache and database)
  - Wasted cache space (data written but rarely read is still cached)
  - Extra infrastructure complexity (cache must manage database writes)
  - If cache fails, writes fail (cache is in the critical write path)

Write-through alone is insufficient:
  Data loaded directly into the database (batch jobs, migrations)
  bypasses the cache, causing staleness.
  Combine with read-through to handle this case.

Write-Behind (Write-Back)

The application writes to the cache, which asynchronously writes to the database later. This decouples write latency from database performance.

How It Works

Write path:
  1. Application writes data to cache
  2. Cache acknowledges the write immediately
  3. Cache queues the write for asynchronous database update
  4. Background process flushes queued writes to database

Timeline:
  T0: Application writes to cache (acknowledged in 1ms)
  T1: Cache queues the write
  T2: Background flush writes to database (50ms, but non-blocking)

Pseudocode:
  function updateUser(userId, data):
    cache.put("user:" + userId, data)
    // Returns immediately. Database write happens later.

  // Background process (managed by cache):
  function flushToDatabase():
    while true:
      batch = writeQueue.drain(maxSize=100, maxWait=5seconds)
      database.batchUpdate(batch)

When to Use

  • Write-heavy workloads where database write latency is a bottleneck
  • When you can tolerate a window where database is behind the cache
  • Batch write optimization (multiple writes to the same key collapse into one database write)
  • Absorbing write spikes without overloading the database

Trade-Offs

Pros:
  - Very fast write acknowledgment (only cache write)
  - Database write batching improves throughput
  - Absorbs write spikes gracefully
  - Multiple rapid updates to the same key result in one database write

Cons:
  - Risk of data loss if cache crashes before flushing to database
  - Database is temporarily behind the cache (inconsistent)
  - Complex failure handling (what happens if database write fails?)
  - Harder to debug (timing-dependent behavior)
  - Not suitable for data that must be immediately durable

Real-world usage:
  CPU write-back caches use this pattern at the hardware level.
  Some ORM frameworks batch database writes similarly.

Refresh-Ahead

The cache proactively refreshes items before they expire, so reads never hit a cold cache for popular items.

How It Works

Configuration:
  TTL = 300 seconds
  Refresh-ahead factor = 0.8 (refresh at 80% of TTL)

Timeline:
  T0:   Item cached with 300s TTL
  T240: Item is at 80% of TTL (240/300)
        Cache triggers background refresh from database
  T245: Background refresh completes, TTL resets to 300s
  T300: Original TTL would have expired, but item was already refreshed

Without refresh-ahead:
  T300: TTL expires
  T301: Next read is a cache miss (slow)
  T302: Database queried, cache repopulated

With refresh-ahead:
  T240: Background refresh starts
  T245: Cache updated with fresh data
  T300: Item is still fresh (TTL was reset at T245)
  No cache miss for active items.

When to Use

  • Items that are accessed frequently and consistently
  • When cache miss latency is unacceptable for hot items
  • Predictable access patterns where you know which items stay hot
  • Combined with cache-aside for items that may go cold

Trade-Offs

Pros:
  - Eliminates cache misses for popular items
  - Smooth, predictable latency (no periodic spikes from TTL expiry)
  - Background refresh does not block reads

Cons:
  - Wastes resources refreshing items that might not be read again
  - Adds complexity (background refresh logic, scheduling)
  - Needs accurate prediction of which items stay hot
  - If refresh fails, falls back to cache miss behavior

Choosing the Right Pattern

Decision framework:

Read-heavy, simple application:
  -> Cache-aside
  Most common, simplest to implement.

Read-heavy, want simpler application code:
  -> Read-through
  Cache handles miss logic internally.

Strong consistency required:
  -> Write-through + Read-through
  Cache always matches database.

Write-heavy, latency-sensitive:
  -> Write-behind
  Fast writes, async database update.

Latency-critical for popular items:
  -> Refresh-ahead + Cache-aside
  Proactive refresh for hot items, lazy load for cold items.

Common production combinations:
  Cache-aside + TTL:
    Simple and effective for most workloads.

  Read-through + Write-through:
    Full cache management, strong consistency.

  Cache-aside + Write-behind:
    Fast reads and writes, eventual consistency.

  Cache-aside + Refresh-ahead:
    Zero-miss reads for hot items, lazy for cold items.

Real-World Pattern Usage

Amazon DynamoDB Accelerator (DAX) implements read-through and write-through. Applications point to DAX instead of DynamoDB. DAX handles caching transparently, returning cached results for reads and writing through to DynamoDB on writes.

Hibernate ORM uses read-through and write-through for its second-level cache. Entity reads check the cache first, and writes update both cache and database.

Facebook's Memcache deployment uses cache-aside extensively. The application manages all cache interactions. They chose this pattern because it gives maximum control and their workload is overwhelmingly read-heavy.

Redis supports write-behind patterns through Redis Gears or application-level implementation. Some teams use Redis Streams as a write-behind queue.

Common Pitfalls

  • Using write-behind for critical data: If the cache crashes before flushing to the database, queued writes are lost. Never use write-behind for financial transactions or data that must be immediately durable.
  • Write-through without considering write volume: Every write pays the cost of both cache and database operations. For high-volume writes, this doubles your write overhead.
  • Refresh-ahead for long-tail access patterns: If most items are accessed only once or twice, refresh-ahead wastes resources refreshing items nobody will read again.
  • Not handling cache failure in write-through: If the cache is unavailable, should writes fail or bypass the cache? Decide this upfront.
  • Mixing patterns without a clear strategy: Using different patterns for different data types is fine, but document which pattern applies where. Inconsistency leads to bugs.
  • Invalidate vs update confusion: Cache-aside typically invalidates (deletes) on write. Updating the cache on write is a different pattern with different race condition characteristics.

Key Takeaways

  • Cache-aside is the right default pattern for most applications. It is simple, well-understood, and handles cache failures gracefully.
  • Write-through provides strong consistency between cache and database at the cost of higher write latency.
  • Write-behind dramatically improves write performance but risks data loss and adds operational complexity.
  • Read-through simplifies application code by moving cache miss logic into the cache layer itself.
  • Refresh-ahead eliminates cache misses for popular items but wastes resources on items with unpredictable access patterns.
  • In practice, systems combine multiple patterns: cache-aside for general data, write-through for consistency-critical data, and refresh-ahead for latency-sensitive hot items.