Engineering Atlas › System Design Fundamentals

Cache-aside, write-through and write-back

15 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

Two independent choices

Lists of five or six "caching patterns" hide the structure. Every cache answers two questions: on a read miss, who loads the value; and on a write, is the caller told "done" before or after the source of truth has it? A strategy is one read policy plus one write policy. Cache-aside with delete-on-write is the default covered in the caching overview; this lesson covers what the other combinations promise.

The read path

Cache-aside puts the logic in the application: check the cache, on a miss read the source and populate the cache. The cache stays a dumb key-value store, but every service repeats the logic and must get invalidation right.

Read-through moves the loading into the cache layer. The caller asks only the cache; on a miss the cache calls a registered loader. Guava and Caffeine LoadingCache, Hazelcast MapLoader, and DynamoDB Accelerator (DAX) work this way; on a DAX miss, the cluster reads DynamoDB and stores the result before answering (DAX docs). Centralising the loader is what makes it practical to collapse concurrent misses for the same key into one load (covered in the cache stampede lesson) and to apply negative caching consistently.

Refresh-ahead reloads a value before it expires, so hot keys never take a miss. Caffeine's refreshAfterWrite marks an entry eligible for refresh after a duration; the next read returns the old value immediately and triggers an asynchronous reload, and if the reload throws, the old value stays (Caffeine wiki). Combined with a longer expireAfterWrite, it gives bounded staleness at hit latency. Trigger refreshes from reads, not a timer over every key, or you keep refreshing keys nobody reads.

The write path

Write-around (or write-invalidate) writes only the source and deletes the cache entry; the next read reloads. It is simple and safe, and unread data never occupies memory.

Write-through writes the source and the cache together, synchronously, before acknowledging. Reads right after a write are warm and fresh. DAX's item cache works this way: the write goes to DynamoDB first, then to the cache, and succeeds only if both do (DAX docs). Write-through covers only the cache it writes to. DAX's separate query cache is not touched by item writes, so a Query result stays stale until its own TTL ends, and writes that go straight to DynamoDB bypass the item cache entirely (DAX consistency). The other costs are two writes on the critical path, caching data that may never be read, and partial failure: if the commit succeeds and the cache update fails, the key must be invalidated or it serves the old value until the TTL ends.

Write-behind (write-back) acknowledges once the cache has the write and flushes to the source later. That enables two optimisations no other strategy has. Coalescing: ten updates to one key within the flush window become one write of the latest value. Batching: hundreds of keys go to the database in one statement, amortising round trips and commits. The price is a durability window: anything acknowledged but not flushed is lost if the cache node dies, unless the cache replicates or logs it.

Getting write-behind right

Four details separate write-behind from a data-loss bug. Reads must see pending writes: a dirty key must never be reloaded from the stale source or expired. A failed flush must be retried, but never over a newer value written after the batch was taken. The pending queue must be bounded, or an hour-long database outage becomes an out-of-memory crash that loses everything. And coalescing does not preserve order across keys, so rows with constraints between them (an order and its line items) can be flushed in a violating order.

Negative caching

If misses for absent keys are not cached, every request for them reaches the source. That is normal for some workloads (is this username taken?) and an attack vector for others (random IDs that bypass the cache). A "not found" marker with a short TTL fixes it. DNS formalised this in RFC 2308: resolvers cache NXDOMAIN for the smaller of the SOA record's TTL and its MINIMUM field, and the RFC suggests capping negative TTLs at one to three hours (RFC 2308). Keep negative TTLs much shorter than positive ones and let a write replace the marker immediately.

Failures are a separate case. Never store an error or timeout as "not found", or an outage outlives itself by the TTL. But not caching failures at all lets every caller hammer a broken dependency. DNS settled this in 2023: RFC 9520 requires resolvers to cache resolution failures (SERVFAIL, timeouts, validation failures) for at least 1 second and at most 5 minutes, separately from NXDOMAIN (RFC 9520). Amazon's guidance is the same: keep failures in a negative cache with their own, shorter TTL (AWS Builders' Library).

The same problem shape elsewhere

Write-behind combines the duplicates and multi-step consistency shapes: coalescing removes duplicate work, and the gap between acknowledgement and persistence is a two-step update that can fail halfway. An offline-first mobile app has the same structure. It shows local edits immediately, coalesces repeated edits to a field, and syncs in batches when the network returns, and its hard bugs are the same: edits lost when the app is killed, and retries that overwrite newer changes.

Down the stack

Layer 1The contract

State each strategy's promise in three properties. Staleness: cache-aside and write-around readers may see a value up to the TTL old; write-through readers of the same cache see fresh data except after a partial failure; refresh-ahead bounds staleness by the expiry at hit latency. Read-your-writes: guaranteed by write-through and write-behind for readers of the same cache. Durability window: zero except for write-behind, where it is the flush delay plus queue drain time, unless pending writes are replicated.

Layer 2The mechanism

Hazelcast's MapStore shows the knobs plainly (Hazelcast docs). write-delay-seconds of 0 (the default) means write-through; anything above 0 enables write-behind. write-coalescing, true by default, writes only the latest value of a key changed within the delay window. write-batch-size groups entries into batches for storeAll. Linux's page cache is the canonical write-behind cache: a write() dirties pages and returns, flusher threads wake every dirty_writeback_centisecs (500, so 5 seconds) and write back pages dirty for longer than dirty_expire_centisecs (3000, so 30 seconds), and dirty_background_ratio and dirty_ratio start background writeback and then make writing processes do the writeback themselves when too much memory is dirty (kernel docs). fsync() is how an application closes the durability window for one file. On the read side, NGINX's proxy_cache_lock lets one request populate a cache element while others wait, proxy_cache_use_stale updating serves the old response during refresh, and proxy_cache_valid 404 1m is negative caching in one directive (NGINX docs).

Layer 3The machine

CPUs use write-back caches for the same reason. A store marks the 64-byte line dirty (Modified in MESI), and the line reaches memory only when evicted or requested by another core, so a loop incrementing a counter a billion times touches memory about once.

At the storage level the gain is commit cost. A durable commit needs a flush to stable media: tens of microseconds on an NVMe drive with power-loss protection, and around a millisecond or more on many network-attached cloud volumes. Writing 1,000 rows as 1,000 transactions pays that 1,000 times; writing them as one multi-row INSERT ... ON CONFLICT DO UPDATE pays it once and sends one round trip of roughly 0.5 ms instead of a thousand. The cost is memory: a million pending 200-byte entries is a few hundred megabytes that a crash discards.

Why it's built this way

  1. The source of truth is slow and durable, the cache fast and volatile, so every strategy is a choice about which one a caller waits for.
  2. Waiting for the source on every write is safe but slow, so write-through is chosen when reads after writes matter more than write latency.
  3. Many writes are repeated or small, so delaying them allows coalescing and batching, which is write-behind.
  4. Delay means acknowledged data lives only in volatile memory, so write-behind needs bounded queues, replication or fsync-like checkpoints.
  5. Misses for absent keys are as expensive as misses for present ones, so caches remember absence too, briefly.

Diagram

sequenceDiagram
  participant App
  participant C as Cache
  participant DB as Database
  App->>C: put(cart:7, v1) at t=0
  C-->>App: ok (dirty since t=0)
  App->>C: put(cart:7, v2) at t=2
  C-->>App: ok (coalesced)
  App->>C: get(cart:7)
  C-->>App: v2 (pending write, no load)
  C->>DB: flush at t=5: upsert cart:7 = v2
  DB-->>C: error
  App->>C: put(cart:7, v3) at t=6
  C->>C: requeue skipped, v3 is newer
  C->>DB: flush at t=11: upsert cart:7 = v3
  DB-->>C: ok

Worked example

A messaging app records each user's "last active" timestamp. There are 2 million concurrently active users, and clients send a heartbeat every 30 seconds, which is about 66,700 writes per second. Writing each one to Postgres as its own transaction would need a large primary just for a field nobody reads with second-level precision.

With write-through, the database still sees 66,700 writes per second; only reads benefit. With write-behind and a 5-minute flush delay, coalescing turns ten heartbeats per user into one write: 2 million rows every 300 seconds, about 6,700 rows per second. Batching 500 rows per upsert makes that about 13 statements per second, each a single commit. The primary goes from saturated to nearly idle.

The durability window is up to 5 minutes of "last active" updates, lost if a cache node dies before flushing. For presence that is acceptable: the next heartbeat repairs it within 30 seconds. The pending queue is at most 2 million entries of about 60 bytes (user ID, timestamp, bookkeeping), about 120 MB per cluster, bounded because coalescing caps it at one entry per user.

Message history must not use this strategy: an acknowledged but lost message is a correctness bug. Messages use write-around with cache-aside reads; only presence uses write-behind.

In the wild

Trade-offs

StrategyStrengthsWeaknessesChoose when
Cache-asideSimple, cache is optional, caches only what is readLogic repeated in every service, first read after write missesDefault for read-heavy data
Read-throughOne loader, enables coalescing and negative cachingCache layer must know the sourceMany callers share one data source
Refresh-aheadHot keys never miss, bounded stalenessWasted refreshes on keys going coldHot, expensive, slowly changing values
Write-throughFresh, warm reads after writesSlower writes, caches unread data, partial failuresRead-after-write is common and must be fast
Write-aroundSafe, no cache pollutionRead after write missesWritten data is rarely read soon
Write-behindFast writes, coalescing, batchingDurability window, retry and ordering complexityHigh write rates of loss-tolerant or replicated data

Pitfalls in production

Leadership lens

Check yourself

  1. Why can write-behind reduce database writes while write-through cannot?

    Show answer

    Write-behind holds writes for a window, so repeats coalesce and many keys share one commit. Write-through must persist every write before acknowledging.

  2. A write-behind cache evicts a dirty entry under memory pressure. What does the next read return, and why is that bad?

    Show answer

    It misses and loads the old value from the database, so an acknowledged write silently disappears. Dirty entries must be flushed before eviction or pinned.

  3. Why should negative entries have a shorter TTL than positive ones?

    Show answer

    Keys become present when created, and a long negative TTL hides new data for that long.

  4. A flush of key k fails, and k was written again before the retry. What should the retry do?

    Show answer

    Drop the failed value for k, because the newer pending write supersedes it. Retrying the old value would overwrite newer data or reorder the writes.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →