system design

Caching Strategies Explained: Cache-Aside, Write-Through, Read-Through & Write-Behind (2026)

Cache-aside, read-through, write-through, write-behind — when to use each caching strategy, with Python and Node.js implementations, invalidation patterns, and cache hit-ratio metrics.

By Akash Sharma·18 min read
#caching
#redis
#system design
#cache-aside
#write-through
#backend
#performance

The four main caching strategies are cache-aside, read-through, write-through, and write-behind. Cache-aside (lazy loading) is the most common: your app checks the cache first, fetches from the database on a miss, and stores the result. Read-through delegates the miss-fetch to the cache layer itself. Write-through keeps the cache and database in sync on every write — strong consistency, slightly slower writes. Write-behind (write-back) writes to the cache immediately and flushes to the database asynchronously — fastest writes, small risk of data loss.

Choosing between them depends on whether your workload is read-heavy or write-heavy, and how much staleness you can tolerate.

Why Strategy Matters

A cache sits between your application and your database. The strategy defines two things:

  1. How data gets into the cache — on read? on write? pre-loaded?
  2. How writes are handled — update cache first? DB first? both? async?

Getting this wrong causes either stale data (cache has old values) or a cold cache (frequent misses that hammer the DB every time). At scale, a misconfigured caching strategy can turn a database that handles 5,000 queries/second into one that falls over at 500.

Cache-Aside Pattern: How Lazy Loading Works

Cache-aside is the default strategy for most production systems. The application manages the cache directly — the cache has no knowledge of the database.

Read path:

  1. Check cache first
  2. Cache hit → return data immediately
  3. Cache miss → fetch from DB, store in cache with TTL, return data

Write path:

  1. Write to DB
  2. Invalidate (delete) the cached value — next read fetches fresh

Python Implementation

python
import redis
import json
 
r = 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 = r.get(cache_key)
    if cached:
        return json.loads(cached)
 
    # Step 2: cache miss — fetch from DB
    user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
 
    # Step 3: store in cache with 5-minute TTL
    r.setex(cache_key, 300, json.dumps(user))
 
    return user
 
def update_user(user_id: int, data: dict):
    db.execute("UPDATE users SET name = %s WHERE id = %s", (data["name"], user_id))
    # Invalidate cache — next read will fetch fresh from DB
    r.delete(f"user:{user_id}")

Node.js Implementation

javascript
import { createClient } from 'redis';
const client = await createClient().connect();
 
async function getUser(userId) {
  const cacheKey = `user:${userId}`;
 
  const cached = await client.get(cacheKey);
  if (cached) return JSON.parse(cached);
 
  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
 
  await client.setEx(cacheKey, 300, JSON.stringify(user));
  return user;
}
 
async function updateUser(userId, data) {
  await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);
  await client.del(`user:${userId}`);
}

Cache Warming

After a deploy or Redis restart, the cache is cold — every request misses and hits the DB. Pre-warm critical data at startup:

python
def warm_cache():
    """Pre-populate cache at startup for hot data."""
    # Fetch the top 1000 most-accessed users (from analytics or DB query)
    hot_users = db.query(
        "SELECT * FROM users ORDER BY last_accessed DESC LIMIT 1000"
    )
    pipe = r.pipeline()
    for user in hot_users:
        cache_key = f"user:{user['id']}"
        pipe.setex(cache_key, 300, json.dumps(user))
    pipe.execute()  # Batch all writes in one round-trip
 
# Call at app startup
warm_cache()

Use pipelining (pipe.execute()) to batch the warming writes into a single Redis round-trip — warming 1,000 keys individually would be 1,000 network calls.

Pros: Only requested data gets cached (no wasted memory). Cache survives Redis restarts — worst case is a miss, not data loss. Simple to implement and reason about.

Cons: First request after a miss is slow (miss penalty). Brief stale window between write and invalidation if you use TTL-based expiry instead of explicit delete.

Best for: Read-heavy workloads. General-purpose. When not all data needs to be cached.

Write-Through vs Write-Behind: Consistency vs Latency

These two strategies handle writes differently. The right choice depends on whether you prioritize consistency or write latency.

Write-Through: When Consistency Matters

Every write goes to cache AND database synchronously before returning to the caller.

python
def update_user(user_id: int, data: dict):
    # Write to DB first
    db.execute("UPDATE users SET name = %s WHERE id = %s", (data["name"], user_id))
 
    # Populate cache from data already in hand — no extra SELECT needed
    r.setex(f"user:{user_id}", 300, json.dumps(data))
javascript
async function updateUser(userId, data) {
  await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);
  await client.setEx(`user:${userId}`, 300, JSON.stringify(data));
}

When to use write-through:

  • User reads data immediately after writing it (e.g., profile update → redirect to profile page)
  • Stale reads are unacceptable (e.g., inventory levels, account balances)
  • Write frequency is low relative to read frequency

Cons: Every write hits both DB and cache — slightly higher write latency. Infrequently-read data still occupies cache memory.

Write-Behind: When Latency Matters

Write to cache immediately and return to the caller. Flush to DB asynchronously in the background.

python
import asyncio
 
write_buffer: dict = {}
 
def update_user(user_id: int, data: dict):
    cache_key = f"user:{user_id}"
    # Update cache immediately — caller sees response right away
    r.setex(cache_key, 300, json.dumps(data))
    # Queue DB write for background processing
    write_buffer[cache_key] = (user_id, data)
 
async def flush_writes():
    """Background task — start with asyncio.create_task() at app startup."""
    while True:
        await asyncio.sleep(1)  # Flush every second
        if write_buffer:
            pending = dict(write_buffer)
            write_buffer.clear()
            for key, (user_id, data) in pending.items():
                # Use an async DB client (asyncpg, databases, SQLAlchemy async)
                await db.execute(
                    "UPDATE users SET name = $1 WHERE id = $2",
                    data["name"], user_id
                )

When to use write-behind:

  • Write-heavy workloads where batching DB writes reduces load (counters, view counts, analytics events, game scores)
  • Write latency is a product requirement, not just a nice-to-have
  • Small data loss is acceptable (e.g., a user's "last seen" timestamp)

Never use for: Financial transactions, order records, anything where losing the last second of writes is unacceptable.

Write-ThroughWrite-Behind
Write latencyDB latency (synchronous)Near-zero (async)
ConsistencyStrongEventual
Data loss riskNoneYes (if cache crashes before flush)
DB write loadEvery writeBatched writes
ComplexityLowHigh

Read-Through Cache: How It Differs from Cache-Aside

Read-through looks identical to cache-aside from the caller's perspective, but the difference is who handles the cache miss. In cache-aside, your application code handles the miss. In read-through, the cache layer handles it transparently.

python
# Cache-aside: YOUR CODE handles the miss
cached = r.get(cache_key)
if not cached:
    data = db.query(...)   # <-- your code does this
    r.setex(cache_key, 300, json.dumps(data))
 
# Read-through: THE CACHE LAYER handles the miss
data = cache.get(cache_key)  # cache fetches from DB internally on miss

Using Read-Through with ORMs

Read-through is most common at the ORM or data-access layer. Hibernate's second-level cache is the canonical example — it sits between your entity code and the database and handles cache population transparently:

java
// Hibernate second-level cache (EhCache or Redis provider)
// No cache code in your application — it's in the ORM config
@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)  // <-- enables read-through
public class User {
    @Id
    private Long id;
    private String name;
}
 
// Application code — no cache logic needed
User user = session.get(User.class, userId);  // Hibernate checks cache first

In Python with SQLAlchemy, you can achieve similar behavior with dogpile.cache:

python
from dogpile.cache import make_region
 
region = make_region().configure(
    'dogpile.cache.redis',
    expiration_time=300,
    arguments={'host': 'localhost', 'port': 6379}
)
 
@region.cache_on_arguments()
def get_user(user_id: int) -> dict:
    # This function is only called on cache miss — dogpile handles the rest
    return db.query("SELECT * FROM users WHERE id = %s", (user_id,))

Pros: Application code has no cache logic — cleaner separation of concerns. Easier to add caching to an existing data layer without touching every call site.

Cons: Less control over loading logic (hard to batch or pre-warm). The first request after a miss is always slow.

Best for: ORM-level caching, data access layers where you want caching to be infrastructure, not application logic.

Cache Invalidation: The Hard Problem

Phil Karlton famously said there are only two hard problems in computer science: cache invalidation and naming things. Here is why cache invalidation is the harder one.

A single database record can be represented under multiple cache keys simultaneously:

plaintext
user:123          ← individual user lookup
user_list:page:1  ← paginated list that includes this user
user_count:active ← aggregate count that changes when user is deactivated
search:name:john  ← search result that includes this user

When a user updates their name, all four keys need to be invalidated. Miss one and you have stale data. The problem compounds as your caching logic grows.

TTL-Based Invalidation

Simplest approach. Set an expiry time — the cache self-invalidates.

python
r.setex("user:123", 300, json.dumps(user))  # Expires in 5 minutes

Acceptable stale window = TTL value. Short TTL = more DB load. Long TTL = more stale data. The right TTL depends on how fast your data changes and how much staleness your users will tolerate.

Event-Driven Invalidation

Explicitly delete cache entries on write. Immediate consistency, but requires you to track all cache keys that relate to a piece of data.

python
def update_user(user_id: int, data: dict):
    db.execute("UPDATE users SET name = %s WHERE id = %s", (data["name"], user_id))
 
    # Invalidate all keys that represent this user
    r.delete(f"user:{user_id}")           # Individual lookup
    r.delete(f"user_list:all")            # Any list caches
    r.delete(f"user_count:active")        # Aggregate counts
    r.delete(f"search:name:{data['old_name']}")  # Search caches

Tag-Based Invalidation

Tag-based invalidation groups cache keys under logical tags so you can invalidate all related keys in one operation. Redis doesn't have this natively, but it is straightforward to implement with sets:

python
def cache_set_with_tags(key: str, value: str, ttl: int, tags: list[str]):
    """Store a cache value and register it under one or more tags."""
    pipe = r.pipeline()
    pipe.setex(key, ttl, value)
    for tag in tags:
        pipe.sadd(f"tag:{tag}", key)     # Add key to the tag's set
        pipe.expire(f"tag:{tag}", ttl)   # Tag set expires with the data
    pipe.execute()
 
def invalidate_tag(tag: str):
    """Delete all cache keys associated with a tag."""
    keys = r.smembers(f"tag:{tag}")
    if keys:
        pipe = r.pipeline()
        for key in keys:
            pipe.delete(key)
        pipe.delete(f"tag:{tag}")
        pipe.execute()
 
# Usage
cache_set_with_tags(
    key=f"user:{user_id}",
    value=json.dumps(user),
    ttl=300,
    tags=["user", f"user:{user_id}", "user_list"]
)
 
# When any user data changes, invalidate the whole group
invalidate_tag("user_list")

CDN Cache Invalidation

CDN cache invalidation is a separate concern from application cache invalidation. When you update a blog post or product page, the CDN may still serve the old version from edge nodes around the world.

bash
# Cloudflare — purge specific URLs
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  --data '{"files": ["https://example.com/products/123"]}'
 
# Cloudflare — purge by cache tag (requires Enterprise plan)
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
  -H "Authorization: Bearer {token}" \
  -H "Content-Type: application/json" \
  --data '{"tags": ["product-123"]}'

For Vercel, Next.js on-demand revalidation handles this at the framework level:

javascript
// app/api/revalidate/route.js
import { revalidatePath } from 'next/cache';
 
export async function POST(request) {
  const { path } = await request.json();
  revalidatePath(path);
  return Response.json({ revalidated: true });
}

Caching at Different Layers

Modern applications cache at multiple layers simultaneously. Each layer has a different scope, latency, and invalidation mechanism.

LayerWhat to CacheTTL RangeInvalidation
Browser cacheStatic assets (JS, CSS, images)Days–months (versioned filenames)Cache-busting via filename hash
CDN / EdgeHTML pages, API responses, imagesMinutes–hoursPurge API or cache tags
API GatewayRate-limit state, auth tokensSeconds–minutesTTL only
Application cache (Redis)DB query results, computed aggregatesSeconds–hoursEvent-driven + TTL
Database query cacheExpensive query results (pg: pg_prewarm)Until DB restart or table writeAutomatic on write

What to Cache at Each Layer

Browser cache: Use long TTLs (1 year) for fingerprinted static assets (main.abc123.js). Cache-Control headers do the work — no application code required.

CDN: Cache full page HTML for anonymous users. Bypass CDN cache for authenticated requests or use Vary headers to separate authenticated vs anonymous responses. Cache API responses for public endpoints (product catalog, pricing).

API Gateway: Cache authentication results to avoid hitting your auth service on every request. Rate-limit counters live here too. AWS API Gateway, Kong, and Nginx all support response caching.

Application cache (Redis): This is where your caching strategy decisions (cache-aside, write-through, etc.) apply. Cache DB query results, user sessions, computed aggregates, third-party API responses. This layer gives you the most control.

Database query cache: PostgreSQL does not have a traditional query cache (unlike MySQL's deprecated one), but pg_prewarm can load table data into shared buffers. Use EXPLAIN ANALYZE to identify slow queries that would benefit from a Redis-level cache instead.

Cache-Aside Deep Dive: Handling the Hard Cases

Preventing Cache Stampede with Mutex Locking

When a hot cache key expires, all concurrent requests miss simultaneously and hammer the DB. Fix it with a distributed lock:

python
import redis
import json
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
def get_user_safe(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
    lock_key = f"lock:user:{user_id}"
 
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
 
    # Only one request rebuilds the cache — others wait for the lock to release
    with r.lock(lock_key, timeout=5):
        # Double-check: another request may have populated the cache while we waited
        cached = r.get(cache_key)
        if cached:
            return json.loads(cached)
 
        user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
        r.setex(cache_key, 300, json.dumps(user))
        return user

Probabilistic Early Expiration

An alternative to locking: refresh the cache slightly before it expires, so expiry never happens while requests are in-flight.

python
import math
import random
import time
 
def get_with_early_expiry(key: str, ttl: int, fetch_fn, beta: float = 1.0):
    """
    Fetch from cache. Probabilistically refresh before expiry.
    Higher beta = more aggressive early refresh.
    """
    result = r.get(key)
    expiry = r.ttl(key)
 
    if result and expiry > 0:
        # gap = how far before expiry to start probabilistic refresh
        gap = -math.log(random.random()) * beta
        if expiry > gap:
            return json.loads(result)
 
    # Fetch fresh value (either miss or probabilistic early refresh)
    fresh = fetch_fn()
    r.setex(key, ttl, json.dumps(fresh))
    return fresh

Cache Hit Ratio and Sizing

Cache hit ratio = cache hits / (cache hits + cache misses). It is the primary metric for cache effectiveness.

  • Above 80%: Healthy. Cache is working.
  • 60–80%: Acceptable but investigate whether TTLs are too short or cache is too small.
  • Below 60%: Cache is not effective. You may be caching the wrong data, TTLs are too short, or the working set is larger than available memory.

Measuring Hit Rate in Redis

bash
# Check hit/miss counts since Redis started
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses'
 
# keyspace_hits:4821903
# keyspace_misses:104231
# Hit rate = 4821903 / (4821903 + 104231) = 97.9%

Prometheus Metrics for Cache Monitoring

python
from prometheus_client import Counter, Histogram
 
cache_hits = Counter('cache_hits_total', 'Total cache hits', ['key_prefix'])
cache_misses = Counter('cache_misses_total', 'Total cache misses', ['key_prefix'])
cache_latency = Histogram('cache_operation_seconds', 'Cache operation latency')
 
def get_user_instrumented(user_id: int) -> dict:
    cache_key = f"user:{user_id}"
 
    with cache_latency.time():
        cached = r.get(cache_key)
 
    if cached:
        cache_hits.labels(key_prefix='user').inc()
        return json.loads(cached)
 
    cache_misses.labels(key_prefix='user').inc()
    user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
    r.setex(cache_key, 300, json.dumps(user))
    return user

Right-Sizing Redis

Redis keeps all data in memory. Right-sizing matters — too small and Redis evicts data unexpectedly; too large and you're paying for unused capacity.

bash
# Current memory usage
redis-cli INFO memory | grep used_memory_human
# used_memory_human: 2.41G
 
# Check eviction policy
redis-cli CONFIG GET maxmemory-policy
# allkeys-lru   (evicts least recently used keys when memory is full)
 
# Set memory limit (example: 4GB)
redis-cli CONFIG SET maxmemory 4gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

Sizing formula: Estimate your working set size (number of cached objects × average object size in bytes). Add 20% headroom for Redis overhead and fragmentation. Start there and monitor the evicted_keys counter in INFO stats — if keys are being evicted unexpectedly, your cache is too small.

Which Strategy to Pick

ScenarioStrategyWhy
General read-heavy APICache-asideOnly requested data gets cached
Write then immediately readWrite-throughCache is fresh by the time of the read
Write-heavy, small loss okWrite-behindBatches DB writes, near-zero write latency
ORM-level, abstractedRead-throughCache logic lives in the data layer
High-traffic with stampede riskCache-aside + mutex lockPrevents thundering herd
Most production systemsCache-aside + TTLSimple, resilient, flexible

Key Takeaways

  • Cache-aside: App checks cache, fetches DB on miss — most common, most flexible
  • Write-through: Writes go to cache + DB together — always fresh, slightly slower writes
  • Write-behind: Writes go to cache, DB updated async — fast writes, risk of data loss
  • Read-through: Cache handles misses automatically — simpler app code, less control
  • Always set TTL — never let cache grow unbounded
  • Cache stampede is real — use mutex locks for hot keys at scale
  • Cache invalidation is hard because one DB record maps to many cache keys — tag-based invalidation helps
  • Measure hit rate with redis-cli INFO stats — target above 80%
  • Cache at multiple layers (browser, CDN, app, DB) — each layer has a different job
  • Start with cache-aside + TTL; add write-through only for data that's read immediately after write

Caching is the cheapest way to scale a slow system. Pick the strategy that matches how your data is written and read.

Frequently Asked Questions

What is a caching strategy and which should I use?

A caching strategy defines how data moves between your application, cache layer, and database. The four main strategies are cache-aside (your app manages cache reads and writes), read-through (the cache layer handles misses), write-through (writes go to cache and DB synchronously), and write-behind (writes go to cache first, DB async). For most production APIs, start with cache-aside. It is the most flexible, easiest to reason about, and resilient to cache failures.

What is the difference between cache-aside and read-through caching?

In cache-aside, your application code checks the cache and on a miss, fetches from the database and populates the cache itself. In read-through, the cache layer handles the miss transparently — your application only ever calls the cache. Read-through simplifies application code but gives you less control over loading logic. Cache-aside is more work upfront but lets you batch loads, add custom logic, and handle edge cases explicitly.

What is cache invalidation and why is it hard?

Cache invalidation is removing or updating cached data when the source of truth changes. It is hard because a single database record can be represented under multiple cache keys simultaneously — individual lookups, paginated lists, aggregate counts, search results. Invalidating all of them reliably, without race conditions, requires careful tracking of which cache keys depend on which data. Tag-based invalidation (grouping keys under logical tags) is the most scalable solution.

How do I choose a cache TTL?

TTL is a tradeoff between data freshness and database load. Short TTL = more DB queries, fresher data. Long TTL = fewer queries, staler data. Set TTL based on how frequently the data changes and how much staleness is acceptable. A starting point: user profiles 5 minutes, product catalog 1 hour, static reference data 24 hours. Combine TTL with event-driven invalidation (delete on write) so frequently-updated data does not wait for TTL expiry.

What is a cache stampede?

A cache stampede (also called a thundering herd) occurs when a popular cache entry expires and many concurrent requests all miss simultaneously, each triggering a database query. At high traffic, this can overwhelm a database that was handling load fine with the cache in place. Fix it with mutex locking — one request rebuilds the cache while others wait — or probabilistic early expiration, which refreshes the cache before it expires rather than after.

Should I use Redis or Memcached?

Use Redis for most production workloads. Redis supports richer data structures (sorted sets, hashes, lists, streams), optional persistence (RDB + AOF), pub/sub messaging, Lua scripting, cluster mode, and atomic operations. Memcached is simpler and multi-threaded by design, making it marginally faster for pure key-value string caching under very high parallelism. But unless you have a specific, measured reason to choose Memcached, Redis is the right default — it covers more use cases and has a larger ecosystem.

How do I measure cache hit rate?

Run redis-cli INFO stats and look at keyspace_hits and keyspace_misses. Hit rate = hits / (hits + misses). A healthy rate is above 80%. Below 60% means your cache is not effective — investigate whether TTLs are too short, the Redis instance is too small (causing evictions), or you are caching data that is rarely read again. Export these counters to Prometheus using the Redis Exporter or instrument them manually in your application code.


Related reading: Redis Caching Explained · Database Indexing Explained · CDN Explained

Enjoyed this article?

Get weekly insights on backend architecture, system design, and Go programming.