Cache-Aside vs Write-Through vs Write-Behind
Cache-aside, write-through, and write-behind define how applications coordinate cached data with authoritative storage. Each pattern changes read latency, write latency, consistency, durability, and failure recovery.
The correct strategy depends on whether the database must be updated synchronously, whether stale reads are acceptable, whether acknowledged writes can wait for persistence, and how the system recovers when the cache, database, network, or background worker fails.
Table of Contents
- Why Cache Write Strategies Matter
- Cache-Aside
- Write-Through
- Write-Behind
- Strategy Comparison
- Production Design Example
- Common Mistakes
- Production Checklist
- Conclusion
Why Cache Write Strategies Matter
A cache is another copy of application data. Once data changes, the system must coordinate at least two storage locations with different durability, latency, and failure characteristics.
Every cached write raises several production questions:
- Which system is authoritative?
- Should the database or cache be updated first?
- Can the client receive success before the database is updated?
- What happens when one write succeeds and the other fails?
- How are duplicate requests and retries handled?
- How are stale or out-of-order updates prevented?
Application write
|
+---- Database
|
+---- Cache
Possible outcomes:
Database succeeds + cache succeeds
Database succeeds + cache fails
Database fails + cache succeeds
Client times out after one or both operations
Cache-aside, write-through, and write-behind place this coordination in different parts of the request path.
| Strategy | Synchronous Write Target | Database Timing | Typical Authority |
|---|---|---|---|
| Cache-aside | Database | Immediate | Database |
| Write-through | Database and cache | Immediate | Database after coordinated write |
| Write-behind | Cache or durable buffer | Delayed | Temporary latest state may exist outside the database |
Cache-Aside
Cache-aside, also called lazy loading, keeps the database authoritative and lets the application manage cached values. Data enters the cache only after a read miss, and writes usually invalidate cached entries after the database transaction commits.
Read Flow
Read request
|
v
Check cache
|
+---- hit ----> Return cached value
|
+---- miss
|
v
Read database
|
v
Store in cache
|
v
Return value
Only requested records consume cache memory. This works well when the database contains a large dataset but the active working set is relatively small.
The first request after expiration is slower because it performs both a failed cache lookup and a database query. Concurrent requests for the same missing key can also overload the database unless the application coalesces them.
Write Flow
The most common write sequence updates the database first and deletes the cached value after commit.
Update request
|
v
Database transaction
|
v
Commit succeeds
|
v
Delete cache key
|
v
Next read reloads committed state
Deleting is usually safer than directly replacing the cache entry. The next read rebuilds the representation from committed database state, including generated values, joins, defaults, and calculated fields.
A cache-aside race can still produce stale data:
Request A misses cache and reads database value v1
Request B updates database to v2
Request B deletes cache key
Request A stores old value v1 in cache
Finite TTLs, version checks, request coalescing, and event-driven invalidation reduce this risk. Strictly consistent workflows should bypass the cache and read authoritative storage.
Python Example
from __future__ import annotations
import asyncio
import json
import logging
import random
from dataclasses import asdict, dataclass
from typing import Protocol
from redis.asyncio import Redis
from redis.exceptions import RedisError
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class Shipment:
shipment_id: str
account_id: int
status: str
carrier_code: str | None
tracking_number: str | None
version: int
class ShipmentRepository(Protocol):
async def get(
self,
account_id: int,
shipment_id: str,
) -> Shipment | None:
...
class ShipmentCacheAsideService:
def __init__(
self,
redis: Redis,
repository: ShipmentRepository,
active_ttl_seconds: int = 60,
completed_ttl_seconds: int = 3_600,
) -> None:
self._redis = redis
self._repository = repository
self._active_ttl_seconds = active_ttl_seconds
self._completed_ttl_seconds = completed_ttl_seconds
self._locks: dict[str, asyncio.Lock] = {}
@staticmethod
def _key(account_id: int, shipment_id: str) -> str:
return (
f"shipment:v1:"
f"account:{account_id}:"
f"id:{shipment_id}"
)
@staticmethod
def _with_jitter(ttl_seconds: int) -> int:
jitter = max(1, int(ttl_seconds * 0.15))
return ttl_seconds + random.randint(-jitter, jitter)
async def get(
self,
account_id: int,
shipment_id: str,
) -> Shipment | None:
key = self._key(account_id, shipment_id)
cached = await self._read_cache(key)
if cached is not None:
return cached
lock = self._locks.setdefault(key, asyncio.Lock())
async with lock:
# Another request may have populated the key while this
# request was waiting for the local per-key lock.
cached = await self._read_cache(key)
if cached is not None:
return cached
shipment = await self._repository.get(
account_id=account_id,
shipment_id=shipment_id,
)
if shipment is None:
return None
ttl_seconds = (
self._completed_ttl_seconds
if shipment.status in {"delivered", "cancelled"}
else self._active_ttl_seconds
)
try:
await self._redis.set(
key,
json.dumps(asdict(shipment)),
ex=self._with_jitter(ttl_seconds),
)
except RedisError:
# Cache population is optional. The authoritative
# database result can still be returned.
logger.exception(
"Cache population failed",
extra={"cache_key": key},
)
return shipment
async def invalidate(
self,
account_id: int,
shipment_id: str,
) -> None:
key = self._key(account_id, shipment_id)
try:
await self._redis.delete(key)
except RedisError:
# Invalidation workers should retry this failure.
logger.exception(
"Cache invalidation failed",
extra={"cache_key": key},
)
raise
async def _read_cache(
self,
key: str,
) -> Shipment | None:
try:
cached = await self._redis.get(key)
except RedisError:
logger.exception(
"Cache lookup failed",
extra={"cache_key": key},
)
return None
if cached is None:
return None
return Shipment(**json.loads(cached))
The cache fails open for reads because PostgreSQL remains authoritative. Invalidation raises an error so a durable event consumer can retry it. TTL jitter reduces synchronized expirations.
The database update and invalidation event should commit in one transaction:
BEGIN;
UPDATE shipments
SET status = :new_status,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = :account_id
AND shipment_id = :shipment_id
AND version = :expected_version
RETURNING version;
INSERT INTO outbox_events (
event_id,
aggregate_type,
aggregate_id,
aggregate_version,
event_type,
payload
)
VALUES (
:event_id,
'shipment',
:shipment_id,
:new_version,
'shipment.updated',
jsonb_build_object(
'account_id', :account_id,
'shipment_id', :shipment_id,
'version', :new_version
)
);
COMMIT;
An outbox consumer deletes the cache key only after the database transaction is durable.
Advantages, Disadvantages, and Use Cases
Advantages:
- The database remains the clear source of truth.
- Only frequently accessed records consume cache memory.
- Cache failures do not need to block authoritative writes.
- The application remains correct when the cache is empty.
- The pattern can be introduced gradually.
Disadvantages:
- The first request after a miss or expiration is slower.
- Concurrent misses can overload the database.
- Invalidation races can temporarily restore stale data.
- Cache-management logic is distributed through application code.
- A full cache outage can redirect large read volume to the database.
When to use:
- product and shipment details
- user profiles
- tenant configuration
- read-heavy APIs
- data with bounded staleness tolerance
- systems where the database must remain authoritative
When not to use: data that must always exist in the cache immediately after a write, or workloads where cache misses cannot safely reach the database.
Write-Through
Write-through updates the database and cache within the synchronous request path. The client receives success only after the durable write completes, and the cache receives the committed representation immediately.
The pattern is usually implemented through a dedicated data-access service. Allowing unrelated application code to write the database and cache independently increases partial-failure risk.
Read and Write Flow
Update request
|
v
Write-through service
|
+---- write database
| |
| v
| commit
|
+---- update cache
|
v
Return success
Database-first write-through is safer because the cache never exposes state that failed database validation.
If the database commits but the cache update fails, the request should not usually be reported as a failed business write. Otherwise, the client may retry an operation that already committed. The service can delete the key, log the cache failure, and allow the next read to repopulate it.
Cache-first write-through is dangerous unless a single system coordinates both writes transactionally. A database failure after a successful cache update can expose data that was never committed.
Python Example
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from redis.asyncio import Redis
from redis.exceptions import RedisError
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
class ConcurrentPreferenceUpdateError(RuntimeError):
pass
@dataclass(frozen=True)
class UserPreference:
account_id: int
user_id: int
timezone: str
page_size: int
version: int
class PreferenceWriteThroughService:
def __init__(
self,
engine: AsyncEngine,
redis: Redis,
ttl_seconds: int = 3_600,
) -> None:
self._engine = engine
self._redis = redis
self._ttl_seconds = ttl_seconds
@staticmethod
def _key(account_id: int, user_id: int) -> str:
return (
f"preference:v1:"
f"account:{account_id}:"
f"user:{user_id}"
)
async def update(
self,
account_id: int,
user_id: int,
timezone: str,
page_size: int,
expected_version: int,
) -> UserPreference:
async with self._engine.begin() as connection:
result = await connection.execute(
text(
"""
UPDATE user_preferences
SET timezone = :timezone,
page_size = :page_size,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE account_id = :account_id
AND user_id = :user_id
AND version = :expected_version
RETURNING
account_id,
user_id,
timezone,
page_size,
version
"""
),
{
"account_id": account_id,
"user_id": user_id,
"timezone": timezone,
"page_size": page_size,
"expected_version": expected_version,
},
)
row = result.mappings().one_or_none()
if row is None:
raise ConcurrentPreferenceUpdateError(
"Preference changed before the update was applied"
)
preference = UserPreference(
account_id=int(row["account_id"]),
user_id=int(row["user_id"]),
timezone=str(row["timezone"]),
page_size=int(row["page_size"]),
version=int(row["version"]),
)
key = self._key(account_id, user_id)
try:
await self._redis.set(
key,
json.dumps(asdict(preference)),
ex=self._ttl_seconds,
)
except RedisError:
# The business update already committed. Remove any old
# cache value and allow the next read to repopulate it.
logger.exception(
"Write-through cache update failed",
extra={
"cache_key": key,
"version": preference.version,
},
)
try:
await self._redis.delete(key)
except RedisError:
logger.exception(
"Failed to remove stale preference cache",
extra={"cache_key": key},
)
return preference
The database commits before Redis is updated. A cache failure does not convert a successful durable update into an ambiguous client retry.
A read path can use the same cache while preserving version information:
from __future__ import annotations
import json
from redis.asyncio import Redis
from redis.exceptions import RedisError
class PreferenceReader:
def __init__(
self,
redis: Redis,
repository: PreferenceRepository,
) -> None:
self._redis = redis
self._repository = repository
async def get(
self,
account_id: int,
user_id: int,
) -> UserPreference | None:
key = (
f"preference:v1:"
f"account:{account_id}:"
f"user:{user_id}"
)
try:
cached = await self._redis.get(key)
if cached is not None:
return UserPreference(**json.loads(cached))
except RedisError:
pass
preference = await self._repository.get(
account_id=account_id,
user_id=user_id,
)
if preference is None:
return None
try:
await self._redis.set(
key,
json.dumps(asdict(preference)),
ex=3_600,
)
except RedisError:
pass
return preference
Advantages, Disadvantages, and Use Cases
Advantages:
- Frequently updated values remain warm.
- Reads immediately after writes usually hit the cache.
- Write coordination is centralized.
- Cache population does not depend on a later read.
- The cached representation can include the committed database version.
Disadvantages:
- Every write performs both database and cache work.
- Client-facing write latency increases.
- Values that are never read again still consume cache capacity.
- Partial failures require clear recovery rules.
- The cache becomes more closely coupled to the write path.
When to use:
- user preferences
- tenant configuration
- feature settings
- small entity summaries read immediately after updates
- data expected to remain consistently warm
When not to use: write-heavy data with low read reuse, large cached objects, or systems where cache latency must never affect the write path.
Write-Behind
Write-behind, also called write-back caching, acknowledges a write before the database is updated. The new state is written to a cache, durable queue, or append-only log, then persisted asynchronously by a worker.
This pattern reduces client-facing latency and allows updates to be batched or combined, but it changes the durability model. The database may lag behind state already acknowledged to the client.
Write and Persistence Flow
Update request
|
v
Durable event or write buffer
|
+---- update cache
|
v
Return success
|
v
Background persistence worker
|
+---- batch or coalesce updates
|
v
Write database
Using volatile cache memory as the only buffer is unsafe. A cache-node failure can lose acknowledged writes before persistence.
A durable log provides replay:
Application
|
v
Durable event stream
|
+---- cache projection
|
+---- persistence worker
|
v
Database
The event stream becomes the recovery source. The cache serves the latest accepted value, while the database eventually receives durable aggregated state.
Python Example
The following example handles shipment-view counters. The workload is high volume, updates are additive, and short persistence delay is acceptable.
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Protocol
from redis.asyncio import Redis
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ViewIncrement:
event_id: str
shipment_id: str
increment: int
class DurableEventSource(Protocol):
async def read_batch(
self,
maximum_events: int,
) -> list[ViewIncrement]:
...
async def acknowledge(
self,
event_ids: list[str],
) -> None:
...
class ViewCounterWriteBehindWorker:
def __init__(
self,
redis: Redis,
engine: AsyncEngine,
event_source: DurableEventSource,
) -> None:
self._redis = redis
self._engine = engine
self._event_source = event_source
async def process_batch(
self,
maximum_events: int = 1_000,
) -> int:
events = await self._event_source.read_batch(
maximum_events
)
if not events:
return 0
increments: dict[str, int] = {}
for event in events:
increments[event.shipment_id] = (
increments.get(event.shipment_id, 0)
+ event.increment
)
async with self._engine.begin() as connection:
for shipment_id, increment in increments.items():
await connection.execute(
text(
"""
INSERT INTO shipment_view_counts (
shipment_id,
view_count,
updated_at
)
VALUES (
:shipment_id,
:increment,
CURRENT_TIMESTAMP
)
ON CONFLICT (shipment_id)
DO UPDATE
SET view_count =
shipment_view_counts.view_count
+ EXCLUDED.view_count,
updated_at = CURRENT_TIMESTAMP
"""
),
{
"shipment_id": shipment_id,
"increment": increment,
},
)
# Acknowledge only after the database commit succeeds.
await self._event_source.acknowledge(
[event.event_id for event in events]
)
logger.info(
"Persisted view-counter batch",
extra={
"event_count": len(events),
"shipment_count": len(increments),
},
)
return len(events)
Blind increments can be duplicated if the worker commits the database transaction but crashes before acknowledging the stream. A stronger implementation stores processed batch identifiers or stream offsets in the same database transaction.
CREATE TABLE write_behind_batches (
consumer_name TEXT NOT NULL,
batch_id TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (consumer_name, batch_id)
);
BEGIN;
INSERT INTO write_behind_batches (
consumer_name,
batch_id
)
VALUES (
'shipment-view-counter',
:batch_id
)
ON CONFLICT DO NOTHING
RETURNING batch_id;
-- Continue only when the batch marker was inserted.
INSERT INTO shipment_view_counts (
shipment_id,
view_count,
updated_at
)
VALUES (
:shipment_id,
:increment,
CURRENT_TIMESTAMP
)
ON CONFLICT (shipment_id)
DO UPDATE
SET view_count =
shipment_view_counts.view_count
+ EXCLUDED.view_count,
updated_at = CURRENT_TIMESTAMP;
COMMIT;
The cache projection can be updated from the same durable stream:
from __future__ import annotations
from redis.asyncio import Redis
class ViewCounterCacheProjector:
def __init__(self, redis: Redis) -> None:
self._redis = redis
async def apply(
self,
event: ViewIncrement,
) -> int:
return int(
await self._redis.incrby(
f"shipment-views:v1:{event.shipment_id}",
event.increment,
)
)
Production systems should make cache projection idempotent as well, or rebuild it from database totals and stream checkpoints after failure.
Advantages, Disadvantages, and Use Cases
Advantages:
- Very low client-facing write latency.
- Multiple updates can be combined.
- Traffic spikes can be buffered.
- Database write amplification can be reduced.
- Useful for high-frequency additive or replaceable state.
Disadvantages:
- Acknowledged writes may not yet exist in the database.
- Volatile buffers can lose data.
- Backlogs increase persistence delay.
- Ordering, replay, and duplication require explicit handling.
- Database validation failures happen after client success.
- Recovery is considerably more complex.
When to use:
- view and impression counters
- telemetry aggregation
- activity metrics
- nonfinancial counters
- frequent updates that can be coalesced
- state rebuildable from a durable event log
When not to use:
- payments and financial ledgers
- inventory reservations
- security-sensitive state
- strict uniqueness workflows
- data requiring immediate relational validation
Strategy Comparison
The main difference is where the system places latency and recovery complexity. Cache-aside optimizes repeated reads, write-through keeps the cache warm synchronously, and write-behind moves database persistence outside the request path.
| Property | Cache-Aside | Write-Through | Write-Behind |
|---|---|---|---|
| Read latency | Low after warm-up | Low because writes populate cache | Low for the latest cached state |
| Write latency | Database write plus invalidation | Database and cache write | Low before asynchronous persistence |
| Database durability | Before success | Before success | After success |
| Cache population | Lazy | Immediate | Immediate or event-driven |
| Stale-read risk | Moderate | Low when coordinated correctly | Cache may be newer than database |
| Lost-write risk | Low | Low with database-first writes | High without durable buffering |
| Operational complexity | Moderate | Moderate | High |
| Best fit | General read-heavy systems | Frequently read values that should stay warm | High-volume delay-tolerant writes |
Consistency and Failure Behavior
Cache-aside: database state is durable before invalidation. If deletion fails, stale data remains until invalidation retry or TTL expiration.
Write-through: database state is durable before the cache is refreshed. If cache update fails, the system should remove the old entry or let the next read reload it.
Write-behind: the database can remain behind acknowledged application state. Durable buffering, lag monitoring, idempotent replay, and dead-letter handling are required.
Timeouts are ambiguous in every pattern. A timeout does not prove that a write failed. Idempotency keys, entity versions, batch identifiers, and conditional updates are required for safe retries.
Choosing the Right Strategy
| Requirement | Preferred Strategy | Reason |
|---|---|---|
| Database must remain immediately authoritative | Cache-aside | Writes commit directly to the database |
| Values are usually read after every write | Write-through | The committed value immediately warms the cache |
| Client-facing write latency must be minimized | Write-behind | Database persistence occurs asynchronously |
| Strict relational validation is required | Cache-aside or database-first write-through | Validation completes before success |
| Many small updates can be combined | Write-behind | Workers can batch or coalesce operations |
| Cache must remain optional | Cache-aside | Cache failure does not need to block writes |
Production Design Example
A logistics platform can use all three strategies because shipment details, user preferences, and view counters have different consistency and durability requirements.
Architecture
Shipment details
|
+--> PostgreSQL authoritative state
|
+--> Redis cache-aside representation
User preferences
|
+--> preference service
|
+--> PostgreSQL
+--> Redis write-through cache
Shipment view counters
|
+--> durable event stream
|
+--> Redis current counter
+--> batch persistence worker
|
v
PostgreSQL
The system applies:
- Cache-aside to shipment details because PostgreSQL must remain authoritative.
- Write-through to preferences because they are read on most requests after updates.
- Write-behind to view counters because updates are additive and can tolerate delayed persistence.
Request and Failure Flows
Shipment update:
- PostgreSQL updates shipment state.
- The same transaction stores an outbox event.
- An invalidation consumer deletes the cache key.
- The next read reloads committed state.
Preference update:
- PostgreSQL validates the expected version.
- The committed representation is written to Redis.
- If Redis fails, the key is deleted or allowed to expire.
- The database update remains successful.
View-counter update:
- The API appends an increment to a durable stream.
- A projector updates Redis.
- A persistence worker batches increments into PostgreSQL.
- Batch identifiers prevent duplicate database increments.
Redis failure: shipment and preference reads fall back to PostgreSQL with bounded concurrency. View events remain in the durable stream until Redis recovers.
Database failure: shipment and preference writes fail because synchronous durability is required. View events continue accumulating until queue retention or operational limits are approached.
Worker failure: write-behind persistence lag grows, but unacknowledged events remain available for replay.
Network timeout: idempotency keys and version checks prevent blind duplicate writes.
Monitoring
| Strategy | Critical Metrics | Primary Risk |
|---|---|---|
| Cache-aside | Hit ratio, miss rate, load latency, invalidation failures | Cache misses overload the database |
| Write-through | Write latency, cache update failures, stale-key deletions | Database commits while cache remains stale |
| Write-behind | Queue depth, oldest event age, retries, dead letters | Persistence lag exceeds business or retention limits |
Cross-strategy monitoring should also include cache command latency, evictions, database fallback traffic, stale-read incidents, duplicate processing, and recovery duration after cache or database failure.
Common Mistakes
| Mistake | Production Impact | Better Approach |
|---|---|---|
| Using one strategy for every data type | Correctness and latency requirements become mixed | Select the strategy per data responsibility |
| Updating cache before the database commits | Uncommitted values can become visible | Commit first unless one transactional system coordinates both writes |
| Returning failure after the database committed | Client retries can duplicate the business operation | Use idempotency and treat cache refresh as recoverable |
| Using volatile Redis memory as the write-behind log | A cache failure loses acknowledged writes | Use a durable queue or append-only event stream |
| Ignoring event ordering | Older values overwrite newer state | Use versions, sequence numbers, or ordered partitions |
| Using cache-aside without request coalescing | Popular expired keys create database spikes | Use per-key locks or distributed coalescing |
| Directly reconstructing complex cache entries after writes | Generated and joined fields can be missing | Delete the key and rebuild from committed state |
| Using write-through for low-reuse data | Cache capacity and write throughput are wasted | Warm only data with proven read demand |
| Running write-behind without backlog limits | Lag grows until recovery becomes impractical | Alert on depth, age, throughput, and retention headroom |
| Using write-behind for payments or inventory | Accepted writes may fail or disappear later | Persist critical state synchronously |
| Omitting finite TTLs | Failed invalidations leave stale values indefinitely | Use expiration as a final recovery boundary |
| Monitoring only hit ratio | Write lag and partial failures remain hidden | Monitor read, write, invalidation, and persistence paths |
Production Checklist
- Define the authoritative store for every cached value.
- Select a caching strategy for each workload independently.
- Commit authoritative database writes before exposing cached state.
- Use idempotency keys and entity versions.
- Keep finite TTLs even with explicit invalidation.
- Coalesce concurrent cache-aside misses.
- Use durable buffering for acknowledged write-behind operations.
- Prevent stale events from overwriting newer versions.
- Define recovery for partial database and cache success.
- Alert on invalidation and cache-update failures.
- Alert on write-behind queue age and depth.
- Load-test cache outages and cold-cache recovery.
- Test duplicate delivery and worker restart behavior.
- Protect the database with bounded fallback concurrency.
- Keep financial, inventory, and security-critical writes synchronous.
Conclusion
Cache-aside is the safest general-purpose pattern when the database must remain authoritative. Write-through keeps frequently read data warm but adds cache work to synchronous writes. Write-behind reduces write latency and database pressure but requires durable buffering, ordering, replay, and lag management.
Key Takeaway: Use cache-aside for authoritative read-heavy data, write-through when committed values should immediately warm the cache, and write-behind only when delayed database persistence is acceptable and every acknowledged write is protected by a durable replayable log.
More Articles to Read
- Caching Explained: Improving Performance Without Overloading Databases
- CDN vs Reverse Proxy vs Application Cache
- Designing Multi-Level Caching Architectures
- Cache Invalidation Strategies for Production Systems
- Preventing Cache Stampedes and Hot Keys
- Caching Best Practices for Distributed Applications
Comments (0)