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.
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:
- How data gets into the cache — on read? on write? pre-loaded?
- 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:
- Check cache first
- Cache hit → return data immediately
- Cache miss → fetch from DB, store in cache with TTL, return data
Write path:
- Write to DB
- Invalidate (delete) the cached value — next read fetches fresh
Python Implementation
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
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:
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.
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))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.
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-Through | Write-Behind | |
|---|---|---|
| Write latency | DB latency (synchronous) | Near-zero (async) |
| Consistency | Strong | Eventual |
| Data loss risk | None | Yes (if cache crashes before flush) |
| DB write load | Every write | Batched writes |
| Complexity | Low | High |
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.
# 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 missUsing 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:
// 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 firstIn Python with SQLAlchemy, you can achieve similar behavior with dogpile.cache:
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:
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 userWhen 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.
r.setex("user:123", 300, json.dumps(user)) # Expires in 5 minutesAcceptable 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.
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 cachesTag-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:
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.
# 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:
// 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.
| Layer | What to Cache | TTL Range | Invalidation |
|---|---|---|---|
| Browser cache | Static assets (JS, CSS, images) | Days–months (versioned filenames) | Cache-busting via filename hash |
| CDN / Edge | HTML pages, API responses, images | Minutes–hours | Purge API or cache tags |
| API Gateway | Rate-limit state, auth tokens | Seconds–minutes | TTL only |
| Application cache (Redis) | DB query results, computed aggregates | Seconds–hours | Event-driven + TTL |
| Database query cache | Expensive query results (pg: pg_prewarm) | Until DB restart or table write | Automatic 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:
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 userProbabilistic Early Expiration
An alternative to locking: refresh the cache slightly before it expires, so expiry never happens while requests are in-flight.
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 freshCache 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
# 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
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 userRight-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.
# 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-lruSizing 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
| Scenario | Strategy | Why |
|---|---|---|
| General read-heavy API | Cache-aside | Only requested data gets cached |
| Write then immediately read | Write-through | Cache is fresh by the time of the read |
| Write-heavy, small loss ok | Write-behind | Batches DB writes, near-zero write latency |
| ORM-level, abstracted | Read-through | Cache logic lives in the data layer |
| High-traffic with stampede risk | Cache-aside + mutex lock | Prevents thundering herd |
| Most production systems | Cache-aside + TTL | Simple, 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.
Related Posts
Continue reading with these related posts
Redis Caching Explained: How It Works, Patterns, and Code Examples
Redis is an in-memory data store used as a cache to reduce database load and latency. Learn cache-aside, TTL, stampede prevention, eviction policies, and complete Python/Node.js code examples.
System Design Interview: How to Design a URL Shortener like bit.ly
Complete system design walkthrough for a URL shortener like bit.ly. Covers Base62 encoding, database schema, caching with Redis, custom aliases, expiry, analytics at scale, and global distribution.
CDN Explained: How Content Delivery Networks Work (2026)
A CDN serves content from servers near your users — cutting latency from 300ms to 20ms. Covers edge nodes, cache headers, Cloudflare vs CloudFront, and DDoS protection.