Cloud & Infrastructure · Architecture Patterns
Caching Strategies Explained: Cache-Aside, Write-Through, and Write-Behind
Most teams reach for cache-aside by default and never ask if it's actually the right pattern. Here's how the three main caching strategies behave under real traffic, the consistency gap each one leaves open, and how to pick between them.
Abhishek Gupta
7 min read
Sponsored
A product page loads instantly for the first hour after a price change, then suddenly shows the old price to a fraction of users for the next six. Nobody touched the database twice. The cache did exactly what it was told to do, it just wasn’t told what to do when the underlying data changed. That gap, between “the cache is fast” and “the cache is correct,” is what the choice between cache-aside, write-through, and write-behind actually decides.
Cache-aside: the application does the work
Cache-aside, also called lazy loading, is the pattern most teams reach for first, often without deciding to, because it’s the one that layers on top of an existing system with the least disruption. The application checks the cache before hitting the database. On a hit, it returns the cached value. On a miss, it reads from the database, writes the result into the cache, and returns it.
def get_user(user_id):
cached = redis_client.get(f"user:{user_id}")
if cached is not None:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
redis_client.setex(f"user:{user_id}", 300, json.dumps(user)) # 5 min TTL
return user
The appeal is that the database schema, the write path, none of it needs to change. You add a cache check in front of reads and you’re done. The cost shows up on writes: cache-aside says nothing about what happens to the cached copy when the underlying row changes. If you update a user’s email in the database and don’t also touch the cache, the cache keeps serving the old email until its TTL expires. Most cache-aside implementations pair the write with an explicit cache invalidation:
def update_user_email(user_id, new_email):
db.execute("UPDATE users SET email = %s WHERE id = %s", new_email, user_id)
redis_client.delete(f"user:{user_id}") # next read repopulates the cache
Deleting rather than updating the cache entry on write is the safer default. Updating the cache directly means the write path now has to reproduce exactly what a read would have computed, which is an easy place for the two to quietly drift apart. Deleting just means the next read pays the cost of a cache miss once, then repopulates correctly.
Write-through: pay the cost up front
Write-through changes where that cost lands. Instead of invalidating the cache on write and letting the next reader pay for repopulation, the write itself updates the cache and the database together, as part of the same operation:
def update_user_email_write_through(user_id, new_email):
db.execute("UPDATE users SET email = %s WHERE id = %s", new_email, user_id)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
redis_client.setex(f"user:{user_id}", 300, json.dumps(user))
The write is slower, now every write does a database update and a cache update instead of just the database update. What you get in exchange is that the cache is never stale after a successful write; the very next read, from any client, sees the new value immediately. That property matters for data where a brief window of staleness is actually a problem, inventory counts near zero, account balances, anything where “the old value” isn’t just outdated, it’s actively wrong to act on.
Write-through also means the cache stays populated for data that’s written more often than it’s read, which cache-aside handles poorly, since cache-aside only populates the cache in response to a read, and a write-heavy, read-light key spends most of its life as a cache miss anyway under that pattern.
Write-behind: fast writes, deferred risk
Write-behind, sometimes called write-back, goes further than write-through in the same direction: the write updates the cache immediately and returns, and the database write happens asynchronously, batched or queued for later.
def update_user_email_write_behind(user_id, new_email):
user = redis_client.hgetall(f"user:{user_id}")
user["email"] = new_email
redis_client.hset(f"user:{user_id}", mapping=user)
write_queue.enqueue({"op": "update_email", "user_id": user_id, "email": new_email})
# a background worker drains write_queue and applies updates to the database
This is the fastest of the three for writes, because the caller never waits on the database round trip. It’s also the riskiest. Between the cache write and the queued database write actually landing, the cache holds the only copy of the new value. If the cache crashes, or the queue drops a message, or the worker dies mid-batch, that write is gone, and the database never knows it was supposed to happen. Write-behind is the right trade only when write throughput matters more than the guarantee that every write survives a crash, high-volume metrics ingestion, view counters, activity logs, the kind of data where losing a slice during a rare failure is an acceptable cost for the throughput gained the rest of the time.

Putting the three side by side
| Pattern | Read path | Write path | Failure risk | Best fit |
|---|---|---|---|---|
| Cache-aside | App checks cache, falls back to DB on miss | App invalidates or updates cache after DB write | Stale reads until invalidation or TTL expiry | Read-heavy workloads tolerant of brief staleness |
| Write-through | App reads cache, always populated on write | Cache and DB updated together, synchronously | Slower writes, no staleness window | Reads that must reflect the latest write immediately |
| Write-behind | App reads cache | Cache updated immediately, DB updated asynchronously | Unwritten data lost on cache or queue failure | High-volume writes where occasional loss is acceptable |
This is the same tradeoff shape as picking a load balancing algorithm: there’s no universally correct answer, only a correct answer for the specific read and write pattern in front of you. A system can legitimately use different strategies for different keys, cache-aside for a user profile page, write-through for an inventory count on the same product.
The part every pattern shares: invalidation still has to be deliberate
None of these three patterns solves the classic hard problem of caching on their own: knowing when a cached value is no longer valid. TTLs are the blunt-instrument answer, and they work fine when a few seconds or minutes of staleness is genuinely harmless. They stop working the moment staleness has a real cost, and that’s when explicit invalidation, deleting or updating specific keys the moment their underlying data changes, becomes necessary regardless of which strategy you picked for reads and writes.
This is distinct from HTTP caching, which governs what a browser or CDN holds onto between a client and your server. Cache-aside, write-through, and write-behind are about the cache sitting between your application and your database, a different layer, with a different failure mode: a stale HTTP cache serves an old page; a stale application cache can feed wrong data into business logic that acts on it.
Choosing one
Start with the actual question: what happens if this specific piece of data is stale for a few seconds? If the answer is “nothing important,” cache-aside is the simplest thing that works and the right default. If the answer is “a customer sees the wrong balance” or “we oversell inventory,” write-through’s slower write is a cheap price for correctness. If the answer is “we lose a write we can reasonably afford to lose, but we can’t afford the write latency,” write-behind is the pattern built for exactly that trade. The mistake isn’t picking any one of these, it’s picking cache-aside everywhere by habit and only discovering the staleness window mattered after a customer complaint traces back to it.
Sponsored
More from this category
More from Cloud & Infrastructure
R.01 The AWS CloudFront Outage That Took Down Canvas, Blackboard, and Hugging Face at Once
R.02 Kubernetes 1.36 Haru: What's Actually Worth Upgrading For
R.03 AWS's Trillion-Dollar Billing Bug Is a Warning About Automated Cost Alerts
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored