A checkout service caches order state in Redis and writes it to Postgres. Under load someone notices the database write is the slow part of the request and moves it out of the critical path: write to the cache, acknowledge the customer, flush to Postgres from a background worker.
Latency drops by 40 percent. Everyone is pleased.
Three weeks later a pod is killed during a rolling deploy and 340 orders that customers received confirmation emails for do not exist in the database. The cache had them. The flush had not run yet. The pod is gone.
The optimisation was real and so was the trade. The trade was never written down.
The three strategies differ in one place
All three of these are about the same question: where does the acknowledgement to the caller sit relative to the database write?
flowchart TB
subgraph wt["Write-through"]
A1["write cache"] --> A2["write DB"] --> A3["ack caller"]
end
subgraph wb["Write-behind"]
B1["write cache"] --> B2["ack caller"] --> B3["flush DB later"]
end
subgraph wa["Write-around"]
C1["write DB"] --> C2["ack caller"]
C2 --> C3["cache untouched"]
end
style A3 stroke:#4ade80,stroke-width:2px,color:#fff
style B2 stroke:#ef4444,stroke-width:3px,color:#fff
style C2 stroke:#4ade80,stroke-width:2px,color:#fff
Everything else about them follows from where that arrow lands.
| Write-through | Write-behind | Write-around | |
|---|---|---|---|
| Write latency | cache plus database | cache only | database only |
| Can lose acknowledged writes | no | yes | no |
| Cache holds data just written | yes | yes | no |
| First read after write | hit | hit | miss |
| Pollutes cache with unread data | yes | yes | no |
Write-through is correct and slower than it looks
Write-through updates both stores before telling the caller anything. If the database write fails, the whole operation fails, and the caller knows.
The subtlety is what happens if the cache write succeeds and the database write then fails. Now the cache holds a value that is not in the database, and if you return an error while leaving that value in place, subsequent reads will serve data that does not exist.
def save_order(order):
# Database first. The cache is the derived copy, so it should never
# hold something the source of truth rejected.
db.upsert(order)
cache.set(f"order:{order.id}", order, ex=3600)
return order
Writing the database first and the cache second removes that failure mode. The worst case becomes a database write that succeeded and a cache write that failed, which leaves the cache stale rather than fictional, and the next read repairs it. Stale is recoverable. Fictional is not.
The cost is that every write pays the full database latency plus a cache round trip. You have made writes slower in exchange for the next read being a hit, and that trade only pays off if the data is actually read soon. Which brings us to the strategy people skip.
Write-around is the right default more often than expected
Write-around does not touch the cache on write at all. The database is updated, the cache keeps whatever it had, and the next read repopulates.
That sounds like giving something up until you look at what write-through does to a cache under write-heavy load. Every write inserts an entry. If most written data is never read, those entries occupy memory and evict entries that were being read. You have converted your cache into a buffer for cold data and reduced the hit rate for warm data.
Event logs, audit records, telemetry, bulk imports, order history rows that are written once and read at most once weeks later: all of these are better served by writing around the cache entirely.
The eviction interaction is the part that persuades me. A cache is a fixed size and everything you put in it displaces something else. LFU eviction protects a genuinely hot key from a burst of one-off traffic, and write-through under a write-heavy workload generates exactly that burst, from your own writes rather than from user reads.
The cost of write-around is a guaranteed miss on the first read after any write. If your access pattern is write-then-immediately-read, that is one extra database query per object and probably fine. If it is write-then-read-a-thousand-times, write-through is clearly better. Knowing which one you have is the whole decision.
Write-behind is a durability decision, not a caching one
Write-behind acknowledges from memory and flushes later. It is genuinely much faster, and it is the only one of the three that can lose data a client was told was saved.
def save_counter(key, delta):
cache.incrby(key, delta) # acknowledged from here
flush_queue.put(key) # database write happens later
Whether that is acceptable depends entirely on what the data is. For a view counter, a like tally, a last-seen timestamp or a metrics rollup, losing the last few seconds on a crash is invisible and the throughput gain is large. Batching a thousand increments into one database write is the difference between a workload that fits and one that does not.
For anything a user was told was saved, it is not a performance optimisation. It is a silent durability downgrade, and it usually gets made by someone who was thinking about latency rather than about what happens when the process dies.
Two properties worth being explicit about before choosing it:
The window of loss equals the flush interval, and batching to improve throughput widens that window. Those two knobs are the same knob.
The cache is now the source of truth for unflushed data, which means it needs the durability characteristics of a database. Redis with appendfsync everysec will lose up to a second of writes on a crash, which is a different guarantee from your database’s, and it is the same fsync question at a different layer.
If write-behind is genuinely required, the honest version is not a cache at all. Write to a durable log, acknowledge from the log, and have a consumer apply it to the database. That is the outbox pattern, and it gets you the same latency profile without the loss window, because the acknowledgement is backed by something that survives a restart.
Choosing without a flowchart
The question that resolves most cases is whether written data gets read soon.
Written and read immediately, repeatedly: write-through. A user profile update followed by the profile page rendering is the clear case.
Written and rarely read: write-around. Anything append-only, anything that exists for audit, anything bulk.
Written constantly, read constantly, and the value is cheap to lose: write-behind, with an explicit decision recorded about the loss window.
The mistake I see most often is not picking the wrong one. It is not knowing which one is in use, because the choice was made implicitly by whichever helper method someone reached for. A codebase where some writes are write-through and others are write-around, decided by which repository class the author copied from, has three consistency models and no documentation of any of them.
// SPONSORSHIP
If this research saved you time or improved your architecture, consider sponsoring my work on GitHub. All sponsorships go directly toward infrastructure and further technical research.
[ Become a Sponsor ]