Skip to content
Codeloom
Backend

Caching Strategies: Write-Through, Write-Back, and TTL

Understand the major caching strategies for backend systems. Compare write-through, write-back, write-around, and cache-aside with real-world trade-offs.

·8 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Cache-aside (lazy loading) pattern
  • Write-through vs write-back strategies
  • TTL policies and eviction
  • Choosing the right strategy for your use case

Prerequisites

  • Basic understanding of databases
  • Familiarity with key-value stores like Redis

Why Caching Matters

Databases are the bottleneck in most backend systems. A typical SQL query takes 5-50 milliseconds. A Redis cache lookup takes under 1 millisecond. When thousands of requests hit the same data, caching eliminates redundant database work and dramatically reduces latency.

But caching introduces a new problem: your data now lives in two places. Keeping them in sync is where the complexity lives, and that is exactly what caching strategies address.

Cache-Aside (Lazy Loading)

Cache-aside is the most common caching pattern. 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

  1. Application receives a request.
  2. Check the cache for the data.
  3. If found (cache hit), return it.
  4. If not found (cache miss), query the database.
  5. Store the result in the cache.
  6. Return the result.

Implementation

import redis
import json
from database import db

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

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

    # Step 1: Check cache
    cached = cache.get(cache_key)
    if cached:
        return json.loads(cached)

    # Step 2: Cache miss - query database
    user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
    if not user:
        return None

    # Step 3: Populate cache with TTL
    cache.setex(cache_key, 3600, json.dumps(user))  # 1 hour TTL

    return user

def update_user(user_id: int, data: dict) -> dict:
    # Update database
    user = db.query(
        "UPDATE users SET name = %s, email = %s WHERE id = %s RETURNING *",
        (data['name'], data['email'], user_id)
    )

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

    return user

Trade-offs

Strengths: Only caches data that is actually requested. Cache failures do not break the application since it falls back to the database. Simple to implement and reason about.

Weaknesses: First request for any piece of data is always slow (cache miss). Stale data is possible if something else updates the database directly. The application has to manage cache logic.

Write-Through

With write-through caching, every write goes to both the cache and the database synchronously. The application writes to the cache, and the cache layer writes to the database.

How It Works

  1. Application writes data to the cache.
  2. The cache immediately writes the data to the database.
  3. Both writes must succeed before the operation is considered complete.
  4. Reads always hit the cache first.

Implementation

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

    def get(self, key: str) -> dict | None:
        # Always try cache first
        cached = self.cache.get(key)
        if cached:
            return json.loads(cached)

        # Fallback to database on miss
        record = self.db.find_by_key(key)
        if record:
            self.cache.set(key, json.dumps(record))
        return record

    def put(self, key: str, value: dict) -> dict:
        # Write to database first
        self.db.upsert(key, value)

        # Then update cache
        self.cache.set(key, json.dumps(value))

        return value

    def delete(self, key: str) -> None:
        self.db.delete(key)
        self.cache.delete(key)

Trade-offs

Strengths: Cache is always consistent with the database. No stale reads. Simple mental model: every read after a write is guaranteed to be fresh.

Weaknesses: Every write operation has the latency of both cache and database writes. Data that is written but never read still goes into the cache, wasting memory. Higher write latency compared to cache-aside.

Write-Back (Write-Behind)

Write-back caching writes to the cache immediately and asynchronously flushes to the database later. This prioritizes write speed over immediate consistency.

How It Works

  1. Application writes data to the cache.
  2. The write returns immediately.
  3. A background process periodically flushes dirty cache entries to the database.
  4. The database eventually catches up.

Implementation

import threading
import time
from collections import deque

class WriteBackCache:
    def __init__(self, cache_client, db_client, flush_interval=5):
        self.cache = cache_client
        self.db = db_client
        self.dirty_keys = deque()
        self.flush_interval = flush_interval
        self._start_flusher()

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

    def put(self, key: str, value: dict) -> dict:
        # Write only to cache
        self.cache.set(key, json.dumps(value))
        self.dirty_keys.append(key)
        return value

    def _start_flusher(self):
        def flush_loop():
            while True:
                time.sleep(self.flush_interval)
                self._flush()

        thread = threading.Thread(target=flush_loop, daemon=True)
        thread.start()

    def _flush(self):
        flushed = set()
        while self.dirty_keys:
            key = self.dirty_keys.popleft()
            if key in flushed:
                continue
            flushed.add(key)

            cached = self.cache.get(key)
            if cached:
                try:
                    self.db.upsert(key, json.loads(cached))
                except Exception as e:
                    # Re-queue failed writes
                    self.dirty_keys.append(key)
                    print(f"Flush failed for {key}: {e}")

Trade-offs

Strengths: Very fast writes since only the cache is involved. Batches multiple writes to the database, reducing load. Good for write-heavy workloads.

Weaknesses: Risk of data loss if the cache crashes before flushing. Data in the database is stale until the flush completes. More complex to implement correctly, especially error handling.

Write-Around

Write-around skips the cache on writes entirely. Data goes directly to the database. The cache is only populated on reads (like cache-aside).

Implementation

class WriteAroundCache:
    def __init__(self, cache_client, db_client, ttl=3600):
        self.cache = cache_client
        self.db = db_client
        self.ttl = ttl

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

        record = self.db.find_by_key(key)
        if record:
            self.cache.setex(key, self.ttl, json.dumps(record))
        return record

    def put(self, key: str, value: dict) -> dict:
        # Write only to database, invalidate cache
        self.db.upsert(key, value)
        self.cache.delete(key)  # Force next read to fetch fresh data
        return value

Trade-offs

Strengths: Avoids cache pollution from data that is written but rarely read. Good for workloads where writes greatly outnumber reads.

Weaknesses: First read after a write is always a cache miss. Data may be stale in cache until the entry expires or is invalidated.

TTL Policies

Time-to-live (TTL) is the simplest cache invalidation strategy. Every cached entry has an expiration time. After that time passes, the entry is automatically removed.

Choosing TTL Values

# Rarely changing data: long TTL
cache.setex("config:feature_flags", 86400, json.dumps(flags))  # 24 hours

# Frequently changing data: short TTL
cache.setex(f"user:{user_id}:feed", 60, json.dumps(feed))  # 1 minute

# Session data: match session duration
cache.setex(f"session:{session_id}", 1800, json.dumps(session))  # 30 minutes

# Real-time data: very short or no cache
cache.setex(f"stock:{symbol}", 5, json.dumps(price))  # 5 seconds

Jittered TTL

When many cache entries expire at the same time, all those requests hit the database simultaneously. This is called a cache stampede. Adding random jitter to TTLs spreads out the expiration.

import random

def set_with_jitter(cache, key: str, value: str, base_ttl: int):
    jitter = random.randint(0, base_ttl // 10)  # Up to 10% jitter
    cache.setex(key, base_ttl + jitter, value)

# Instead of all keys expiring at exactly 3600s,
# they expire between 3600s and 3960s
set_with_jitter(cache, "product:1", data, 3600)
set_with_jitter(cache, "product:2", data, 3600)

Eviction Policies

When the cache runs out of memory, it needs to decide which entries to remove. Redis supports several eviction policies:

  • noeviction: Return errors when memory is full. No data loss, but writes fail.
  • allkeys-lru: Evict the least recently used key. Best general-purpose policy.
  • allkeys-lfu: Evict the least frequently used key. Good when some keys are consistently hot.
  • volatile-lru: Only evict keys with a TTL set, least recently used first.
  • volatile-ttl: Evict keys with the shortest remaining TTL.
# Set eviction policy in Redis
redis-cli CONFIG SET maxmemory-policy allkeys-lru

# Set memory limit
redis-cli CONFIG SET maxmemory 256mb

For most applications, allkeys-lru is the right default.

Cache Invalidation Patterns

Event-Driven Invalidation

Invalidate cache entries when the underlying data changes, using events or database triggers:

# After updating a user in the database
def on_user_updated(user_id: int):
    cache.delete(f"user:{user_id}")
    cache.delete(f"user:{user_id}:profile")
    cache.delete("users:list")  # Invalidate list cache too

# Using a message queue for distributed invalidation
def publish_invalidation(entity_type: str, entity_id: int):
    message = json.dumps({"type": entity_type, "id": entity_id})
    redis_client.publish("cache_invalidations", message)

Tag-Based Invalidation

Group related cache entries with tags so you can invalidate them together:

def set_with_tags(cache, key: str, value: str, tags: list[str], ttl: int):
    cache.setex(key, ttl, value)
    for tag in tags:
        cache.sadd(f"tag:{tag}", key)

def invalidate_by_tag(cache, tag: str):
    keys = cache.smembers(f"tag:{tag}")
    if keys:
        cache.delete(*keys)
    cache.delete(f"tag:{tag}")

# Usage
set_with_tags(cache, "product:42", data, ["products", "category:electronics"], 3600)

# When any electronics product changes
invalidate_by_tag(cache, "category:electronics")

Choosing a Strategy

StrategyRead SpeedWrite SpeedConsistencyComplexity
Cache-asideFast (after first)NormalEventualLow
Write-throughFastSlowStrongMedium
Write-backFastVery fastEventualHigh
Write-aroundFast (after first)NormalEventualLow

Use cache-aside when reads are much more frequent than writes and occasional staleness is acceptable. This covers most web applications.

Use write-through when you need strong consistency between cache and database and can tolerate slower writes.

Use write-back for write-heavy workloads where you can tolerate the risk of data loss on cache failure, such as analytics counters or activity logs.

Use write-around when data is written once and read rarely, or when you want to avoid cache pollution from bulk imports.

Wrapping Up

Caching is not a single technique but a family of strategies. Cache-aside gives you the most control with the least complexity. Write-through guarantees consistency at the cost of write latency. Write-back optimizes for write speed but risks data loss. TTL provides a safety net for all strategies, ensuring stale data eventually expires. Start with cache-aside and a sensible TTL, measure your hit rates, and evolve your strategy as your traffic patterns become clearer.