Skip to content
Codeloom
Backend

Caching Patterns: Cache-Aside, Write-Through, and Write-Behind

Understand the three core caching patterns with practical examples. Covers consistency tradeoffs, invalidation strategies, cache stampede prevention, and when to use each pattern.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How cache-aside, write-through, and write-behind patterns work
  • How to prevent cache stampedes and stale data
  • How to choose the right pattern for your use case

Prerequisites

  • Basic understanding of key-value stores (Redis, Memcached)
  • Database query fundamentals
  • Backend application architecture

Caching is the most effective way to reduce latency and database load, but choosing the wrong caching pattern leads to stale data, inconsistencies, or cache stampedes. This guide covers the three fundamental patterns, their tradeoffs, and practical implementations.

Cache-aside (lazy loading)

In cache-aside, the application manages the cache explicitly. It checks the cache first, falls back to the database on a miss, and populates the cache with the result.

Read path:
  1. Check cache for key
  2. Cache hit? Return cached value
  3. Cache miss? Query database
  4. Store result in cache
  5. Return result

Write path:
  1. Write to database
  2. Invalidate (delete) the cache key

Implementation

import redis
import json

cache = redis.Redis(host='localhost', port=6379, decode_responses=True)

def get_user(user_id: int) -> dict:
    cache_key = f"user:{user_id}"

    # check cache
    cached = cache.get(cache_key)
    if cached:
        return json.loads(cached)

    # cache miss: query database
    user = db.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
    if user is None:
        return None

    user_dict = dict(user)
    # populate cache with TTL
    cache.setex(cache_key, 3600, json.dumps(user_dict))  # 1 hour TTL

    return user_dict


def update_user(user_id: int, data: dict):
    # write to database first
    db.execute("UPDATE users SET name = %s WHERE id = %s", (data['name'], user_id))
    db.commit()

    # then invalidate cache
    cache.delete(f"user:{user_id}")

Why delete instead of update the cache?

Deleting the cache key is safer than updating it. Consider this race condition with cache updates:

Thread A: reads user from DB (version 1)
Thread B: reads user from DB (version 2, newer)
Thread B: writes version 2 to cache
Thread A: writes version 1 to cache  <-- stale data wins!

With deletion, the next read triggers a fresh database query, always returning the latest data.

Cache stampede prevention

When a popular cache key expires, many concurrent requests hit the database simultaneously. This is a cache stampede.

Solution 1: Locking

def get_user_with_lock(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
    lock_key = f"lock:{cache_key}"

    cached = cache.get(cache_key)
    if cached:
        return json.loads(cached)

    # try to acquire lock
    acquired = cache.set(lock_key, "1", nx=True, ex=5)  # 5 second lock

    if acquired:
        try:
            user = db.execute("SELECT * FROM users WHERE id = %s", (user_id,)).fetchone()
            user_dict = dict(user)
            cache.setex(cache_key, 3600, json.dumps(user_dict))
            return user_dict
        finally:
            cache.delete(lock_key)
    else:
        # another process is refreshing, wait and retry
        time.sleep(0.1)
        return get_user_with_lock(user_id)

Solution 2: Stale-while-revalidate

def get_user_swr(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
    cached = cache.get(cache_key)

    if cached:
        data = json.loads(cached)
        if data.get('_expires_at', 0) < time.time():
            # stale: return stale data but trigger background refresh
            refresh_cache_async.delay(user_id)
        return data

    # true cache miss
    return refresh_and_return(user_id)

Pros of cache-aside: Simple. Application controls exactly what is cached. Cache failures do not prevent reads (fallback to DB). Only requested data is cached.

Cons: First request for any data is always slow (cold cache). Application code must manage caching logic everywhere.

Write-through

In write-through, every write goes through the cache to the database. The cache is always consistent with the database.

Write path:
  1. Write to cache
  2. Cache synchronously writes to database
  3. Return success only after both succeed

Read path:
  1. Read from cache (always present for previously written data)
  2. Cache miss? Query database and populate cache

Implementation

class WriteThroughCache:
    def __init__(self, cache_client, db_client):
        self.cache = cache_client
        self.db = db_client

    def write(self, key: str, value: dict):
        """Write to both cache and database synchronously."""
        # write to database
        self.db.execute(
            "INSERT INTO kv_store (key, value) VALUES (%s, %s) "
            "ON CONFLICT (key) DO UPDATE SET value = %s",
            (key, json.dumps(value), json.dumps(value))
        )
        self.db.commit()

        # write to cache
        self.cache.setex(key, 3600, json.dumps(value))

    def read(self, key: str) -> dict:
        """Read from cache, fallback to database."""
        cached = self.cache.get(key)
        if cached:
            return json.loads(cached)

        result = self.db.execute(
            "SELECT value FROM kv_store WHERE key = %s", (key,)
        ).fetchone()

        if result:
            value = json.loads(result['value'])
            self.cache.setex(key, 3600, json.dumps(value))
            return value

        return None

Pros: Cache is always consistent with the database. No stale reads after writes. Simplifies read path since data is always in cache after being written.

Cons: Higher write latency (must wait for both cache and DB). Writes data to cache that may never be read. If the cache is down, writes fail (unless you add a fallback).

Write-behind (write-back)

Write-behind writes to the cache immediately and asynchronously flushes to the database. This prioritizes write speed over consistency.

Write path:
  1. Write to cache
  2. Return success immediately
  3. Asynchronously write to database (batched)

Read path:
  1. Read from cache

Implementation

import threading
from collections import defaultdict

class WriteBehindCache:
    def __init__(self, cache_client, db_client, flush_interval=5):
        self.cache = cache_client
        self.db = db_client
        self.dirty_keys = set()
        self.lock = threading.Lock()

        # background flusher
        self.timer = threading.Timer(flush_interval, self._flush)
        self.timer.daemon = True
        self.timer.start()

    def write(self, key: str, value: dict):
        """Write to cache only, mark as dirty."""
        self.cache.setex(key, 7200, json.dumps(value))
        with self.lock:
            self.dirty_keys.add(key)

    def read(self, key: str) -> dict:
        cached = self.cache.get(key)
        return json.loads(cached) if cached else None

    def _flush(self):
        """Periodically flush dirty keys to database."""
        with self.lock:
            keys_to_flush = self.dirty_keys.copy()
            self.dirty_keys.clear()

        for key in keys_to_flush:
            value = self.cache.get(key)
            if value:
                try:
                    self.db.execute(
                        "INSERT INTO kv_store (key, value) VALUES (%s, %s) "
                        "ON CONFLICT (key) DO UPDATE SET value = %s",
                        (key, value, value)
                    )
                except Exception as e:
                    # re-add to dirty set for next flush
                    with self.lock:
                        self.dirty_keys.add(key)
                    logger.error(f"Flush failed for {key}: {e}")

        self.db.commit()

        # reschedule
        self.timer = threading.Timer(5, self._flush)
        self.timer.daemon = True
        self.timer.start()

Pros: Very fast writes (cache-only latency). Database writes are batched, reducing total DB operations. Good for write-heavy workloads like counters or analytics.

Cons: Risk of data loss if the cache crashes before flushing. Reads from the database are stale until the flush completes. Complex failure handling.

Choosing the right pattern

ScenarioPatternReason
General-purpose cachingCache-asideSimple, low risk, handles cache failures gracefully
Read-heavy with consistent readsWrite-throughReads always see latest data
Write-heavy (counters, analytics)Write-behindMinimizes write latency, batches DB operations
User sessionsCache-aside or write-throughNeed consistency, acceptable latency
LeaderboardsWrite-behindHigh write frequency, eventual consistency OK
Product catalogCache-asideRead-heavy, infrequent changes

Cache invalidation strategies

Time-based (TTL)

Set a time-to-live on every cache key. Simple, but data can be stale for up to the TTL duration.

cache.setex("user:42", 300, data)  # expires in 5 minutes

Event-based

Invalidate the cache when the underlying data changes. More complex, but data is never stale.

# publish invalidation event
def update_user(user_id, data):
    db.update_user(user_id, data)
    redis.publish("cache_invalidation", f"user:{user_id}")

# subscriber invalidates cache
def handle_invalidation(message):
    cache.delete(message)

Versioned keys

Include a version in the cache key. Incrementing the version effectively invalidates the old key without explicitly deleting it.

version = cache.incr(f"user:{user_id}:version")
cache_key = f"user:{user_id}:v{version}"

Summary

Cache-aside is the safe default for most applications. Write-through adds consistency guarantees at the cost of write latency. Write-behind maximizes write throughput but risks data loss. In practice, most systems use cache-aside with TTL-based expiration and explicit invalidation on writes. Start there, and move to write-through or write-behind only when you have a specific performance requirement that cache-aside cannot meet.