Quick Answer

Read-through, write-through, and write-behind are three ways a cache stays in sync with its backing database. Read-through has the cache load missing data from the database itself; write-through writes to the cache and database together before returning; write-behind writes to the cache immediately and flushes to the database later in a batch, trading a small risk of data loss for much faster writes.

Three Ways to Keep a Cache and a Database in Sync

If you've read our piece on cache-aside, you know the most common pattern: the application checks the cache first, and on a miss, loads from the database and writes the result into the cache itself. Cache-aside puts the application in charge of both the loading and the writing. The three strategies in this post hand that responsibility to the caching layer instead, and each one makes a different trade-off between write latency, consistency, and how much complexity you're willing to own.

  • Read-through: the cache sits in front of the database for reads. On a miss, the cache itself — not the application — loads from the database and populates itself.
  • Write-through: every write goes to the cache and the database together, synchronously, before the write call returns to the caller.
  • Write-behind (write-back): every write goes to the cache immediately and returns right away; the database is updated later, usually in a batch, by a background process.

To make the differences concrete rather than just descriptive, the rest of this post simulates a tiny cache and a “database” with realistic latency in Python, and shows the actual timing each strategy produces — not just a description of what should happen.

Read-Through: The Cache Owns the Loading

A read-through cache sits directly in the request path for reads. The application only ever talks to the cache; if the cache doesn't have a value, it loads it from the database itself, stores it, and returns it. This is the mirror image of cache-aside, where the application code is the one that notices a miss and does the loading — with read-through, that logic lives once, inside the cache layer, instead of being duplicated across every place in your codebase that reads that data.

class ReadThroughCache:
    def __init__(self, db):
        self.db = db
        self.cache = {}

    def get(self, key):
        if key in self.cache:
            return self.cache[key], "HIT"
        value = self.db.read(key)   # cache miss -> cache itself loads from db
        self.cache[key] = value
        return value, "MISS (loaded from db)"

Running this against a simulated database (with a deliberate 30ms read delay standing in for real network and disk latency) for a key that already exists produces a real, observable pattern: the first call is slow and reports a miss, every call after is instant and reports a hit.

get(user:7) call 1: status=MISS (loaded from db) value={'name': 'existing user, already in the db'}
get(user:7) call 2: status=HIT value={'name': 'existing user, already in the db'}

The main risk read-through introduces is the cache stampede (also called the thundering-herd problem): if a popular key expires or gets evicted and a burst of concurrent requests all miss at the same instant, they can all fall through to the database simultaneously. Production read-through implementations guard against this with per-key locks or “request coalescing” — the first request to miss loads from the database while the others wait on that same in-flight load, instead of each one starting its own.

Write-Through: Every Write Pays the Database's Latency

Write-through applies the same “cache owns it” philosophy to writes. Every write updates the cache and the database together, and — this is the important part — the call doesn't return to the caller until both have succeeded. The cache and the database are never out of sync, not even for a moment, because the write isn't considered done until they agree.

class WriteThroughCache(ReadThroughCache):
    def set(self, key, value):
        t0 = time.perf_counter()
        self.cache[key] = value          # 1. update cache
        self.db.write(key, value)        # 2. update db, SYNCHRONOUSLY, before returning
        elapsed = time.perf_counter() - t0
        return elapsed

Simulating a database with a deliberate 50ms write latency and calling set() three times in a row gives exactly the timing you'd expect from “the caller waits for the database every time”:

set(user:0) returned after 50.6 ms
set(user:1) returned after 50.6 ms
set(user:2) returned after 50.6 ms
total wall time for 3 writes: 152.1 ms
db.write_log has 3 entries (immediate, one per set call)

Every write paid the full database latency, and the database's write log shows all three writes landed immediately — there's no lag between “the application thinks this is saved” and “the database actually has it.” That's the whole point of write-through: strong consistency between cache and database, at the cost of every write being exactly as slow as your slowest backing store.

Write-Behind: Fast Writes, Deferred Durability

Write-behind (also called write-back) keeps the cache update but defers the database write. The set() call updates the cache and puts the write on a queue, then returns immediately — the caller has no idea, and doesn't need to, whether the database has actually been touched yet. A background process drains that queue on its own schedule, usually batching multiple writes together.

class WriteBehindCache(ReadThroughCache):
    def set(self, key, value):
        t0 = time.perf_counter()
        self.cache[key] = value              # 1. update cache immediately
        self.dirty_queue.put((key, value))   # 2. queue for later db write
        elapsed = time.perf_counter() - t0   # returns almost instantly
        return elapsed

Running the same three writes against a write-behind cache, with a background flusher thread that wakes up every 200ms, produces a completely different timing profile:

set(user:0) returned after 0.027 ms  (db.write_log size right now: 0)
set(user:1) returned after 0.019 ms  (db.write_log size right now: 0)
set(user:2) returned after 0.008 ms  (db.write_log size right now: 0)
total wall time for 3 writes: 0.2 ms
db.write_log size immediately after all 3 set() calls: 0  <- writes NOT in db yet
db.write_log size after waiting 350 ms for the flusher: 2
    flushed to db: user:0 = {'name': 'user0'}
    flushed to db: user:1 = {'name': 'user1'}

Three writes returned in a fraction of a millisecond total (0.2ms, versus 152.1ms for the same three writes under write-through above — about 760x faster on this run) and immediately after all three calls returned, the database had recorded zero of them. Even 350ms later, well past one 200ms flush cycle, only two of the three writes had actually landed, because the flusher writes them one at a time and each simulated write still takes 50ms; the third was still in flight when the check ran. That gap between “the cache has it” and “the database has it” isn't a bug in this simulation — it's the exact trade-off write-behind makes on purpose.

That gap is also where the risk lives. If the process crashes before the flusher runs, whatever is sitting in the dirty queue is simply gone:

set('order:42', ...) acknowledged to caller (cache has it, db does not yet)
cache contents: {'order:42': {'status': 'paid'}}
db contents right now (simulating a crash before the next flush): {}

The caller was told the write succeeded. The cache has it. The database — the thing that's supposed to survive a restart — does not. This is exactly why write-behind is used for data where losing the last few seconds of writes is annoying but not catastrophic (view counters, activity logs, analytics events), and why production write-behind implementations back the “dirty queue” with something durable — a write-ahead log on disk, or a message queue like Kafka — rather than the plain in-memory Python queue used here for the demo. An in-memory queue turns “the last few writes might be delayed” into “the last few writes might vanish entirely.”

Which One Should You Actually Reach For

The three strategies aren't competing for the same job; they answer different questions about what you can afford to lose and how fast writes need to return.

  • Use write-through when a stale or lost write is unacceptable — account balances, order status, anything where “the cache said it worked but the database doesn't have it” becomes a support ticket or worse. You're explicitly trading write latency for the guarantee that cache and database never disagree.
  • Use write-behind when you have high write volume and can tolerate losing the last few seconds of it in a crash — view counts, “last seen” timestamps, telemetry, activity feeds. The payoff is real: writes return close to instantly, and because the flusher can coalesce multiple writes to the same key into a single database write (the batch in the simulation above kept only the latest value per key), it also cuts total load on the database compared to writing every update individually.
  • Use read-through (usually paired with one of the above for writes) when you want cache-population logic out of application code entirely, and you're willing to take on the operational work of guarding against cache stampedes.
  • Use cache-aside instead of any of these when you want the simplest possible setup — it needs no special support from your cache client, works with a plain key-value store like a vanilla Redis instance, and is the default most teams reach for first. The cost is that your application code is now responsible for keeping cache and database from drifting apart, which is exactly the job the strategies in this post hand back to the caching layer.

What Happens When Things Fail

Each strategy has a distinct failure mode worth knowing before you pick one.

With write-through, if the database write fails, the cache must not be left holding a value the database never actually got — otherwise the cache is now lying. Correct implementations either fail the whole set() call without touching the cache, or update the cache only after the database write is confirmed. There's no valid in-between state where the write is “sort of” done.

With write-behind, the failure to worry about is exactly the one demonstrated above: anything still sitting in the queue at the moment of a crash is lost unless the queue itself is durable. Real implementations use a persistent, disk-backed queue or log rather than an in-memory one, add retries with backoff for individual failed writes, and route writes that keep failing — malformed data, a constraint violation — to a separate dead-letter queue instead of letting them block every write behind them.

With read-through, the main failure mode is the cache stampede described earlier: a hot key expiring under heavy concurrent read load, sending a spike of simultaneous requests straight to the database. The fix is the same either way — make sure only one request per missing key actually reaches the database, and have the rest wait on that result instead of duplicating the work.

Frequently Asked Questions

Is write-behind the same thing as cache-aside? No. Cache-aside is a pattern where the application checks the cache, and on a write, updates the database directly and then updates or invalidates the cache. Write-behind is specifically about deferring the database write itself to a background process while the cache is updated immediately.
Can I combine read-through and write-through? Yes, and it's a common pairing. Many caching libraries implement both together so the application only ever talks to the cache for reads and writes, and the library handles keeping the database in sync.
What happens to write-behind data if the app crashes before the next flush? Anything still sitting in the in-memory dirty queue is lost unless that queue is backed by something durable, like a disk-based log or a message queue. This is the core risk write-behind trades for its speed.
Does write-through remove the need for cache invalidation? Largely yes, because the cache is always updated in the same operation as the database write, so there's no window where a stale value can sit in the cache the way there can be with cache-aside if an invalidation step is missed or delayed.
Why would a database ever feel faster with write-behind if the write itself isn't faster? Write-behind doesn't make the database write itself faster; it still takes just as long. It makes the caller not have to wait for it, by doing that write in the background instead of on the request's critical path.