Write-through, write-behind, write-around: picking wrong costs you consistency or throughput

> $ stat metadata
Date: 2026.09.11
Time: 6 min read
Tags: [caching, write-through, write-behind, redis, consistency, system-design]

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-throughWrite-behindWrite-around
Write latencycache plus databasecache onlydatabase only
Can lose acknowledged writesnoyesno
Cache holds data just writtenyesyesno
First read after writehithitmiss
Pollutes cache with unread datayesyesno

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.

Frequently Asked Questions

What is the difference between write-through and write-behind caching?
Write-through updates the cache and the database as part of the same operation, and only acknowledges the write once the database has confirmed it, so the acknowledgement means the data is durable. Write-behind updates the cache, acknowledges immediately, and flushes to the database asynchronously afterwards. Write-behind is much faster and is the only one of the two that can lose data the client was told had been saved, because the flush can fail after the acknowledgement.
When should you use write-around caching?
Use write-around when written data is not immediately read back, which covers most event logs, audit records, telemetry and bulk imports. Writing straight to the database and leaving the cache untouched avoids filling it with entries nobody requests, which protects the hit rate for data that is actually read. The cost is a guaranteed miss on the first read of anything just written, which matters only if reads follow writes closely.
Can write-behind caching lose data?
Yes, and that is its defining trade-off. The client receives an acknowledgement once the value is in the cache, before it reaches the database, so anything still buffered is lost if the process or cache node dies before the flush completes. Batching increases both throughput and the size of the window you can lose. It is an appropriate choice for metrics, counters and view tallies where losing a few seconds is acceptable, and inappropriate for anything a user is told was saved.

[ RELATED_LOGS ]

TTFB: -- ms LOAD: -- s PAYLOAD: -- kb