Cache-aside, write-through and write-back
Open in the interactive app: practice, quiz, mock interview →In one minute
- A caching strategy is two separate choices: how reads fill the cache (cache-aside, read-through, refresh-ahead) and how writes reach the source (write-around, write-through, write-behind).
- The choices differ in three measurable properties: how stale a read can be, whether a writer can read its own write, and how much acknowledged data is lost if the cache dies.
- Write-behind is the only strategy where the cache briefly holds data the database does not have. Its payoff is coalescing and batching; its price is a durability window you must name in seconds.
- Negative caching (remembering "not found") protects the source from repeated lookups for missing keys, but only with a short TTL. Failures can be cached too, for seconds, but never as "not found".
- The same choices show up outside servers: the Linux page cache is write-behind, a CPU's L1 is write-back, and an offline-first mobile app is a write-behind queue to the server.
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
- 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.
- 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.
- Many writes are repeated or small, so delaying them allows coalescing and batching, which is write-behind.
- Delay means acknowledged data lives only in volatile memory, so write-behind needs bounded queues, replication or fsync-like checkpoints.
- 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
- Valve Steam, 2015: a caching rule served logged-in pages to other users. On 25 December, with store traffic about 2,000% above a normal sale because of a DoS attack, a second caching configuration was deployed that cached responses for authenticated users. For about 90 minutes, pages for roughly 34,000 users, including billing addresses and partial card digits, may have been shown to others. Valve closed the store, shipped a corrected configuration and confirmed edge caches were purged before reopening. The lesson for strategy design: the cache key and the "may this be cached at all" rule are part of the strategy, not an afterthought (Valve).
- Okta, 2024: a read-through fallback cache keyed on a truncated hash. For AD/LDAP delegated authentication, Okta cached successful logins under a bcrypt hash of user ID, username and password, and used the cache when the AD agent was unreachable. bcrypt ignores input past 72 bytes, so with usernames of 52 or more characters the password fell outside the key, and for users without MFA a previously cached login could succeed with any password. Okta switched the key derivation to PBKDF2 (Okta).
Trade-offs
| Strategy | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Cache-aside | Simple, cache is optional, caches only what is read | Logic repeated in every service, first read after write misses | Default for read-heavy data |
| Read-through | One loader, enables coalescing and negative caching | Cache layer must know the source | Many callers share one data source |
| Refresh-ahead | Hot keys never miss, bounded staleness | Wasted refreshes on keys going cold | Hot, expensive, slowly changing values |
| Write-through | Fresh, warm reads after writes | Slower writes, caches unread data, partial failures | Read-after-write is common and must be fast |
| Write-around | Safe, no cache pollution | Read after write misses | Written data is rarely read soon |
| Write-behind | Fast writes, coalescing, batching | Durability window, retry and ordering complexity | High write rates of loss-tolerant or replicated data |
Pitfalls in production
- Retries that overwrite newer writes. A failed flush requeued blindly replaces a value written after the batch was taken. Requeue only if no newer write for the key is pending, or carry a version and use conditional writes.
- Unbounded pending queues. A database outage turns write-behind into a memory leak, then a crash that loses every pending write. Cap the queue, apply backpressure to writers, and alert on queue age.
- Dirty entries evicted. If eviction or TTL removes an unflushed entry, the write is gone and the next read loads the old value. Pin dirty entries or flush before evicting, as page caches do.
- Caching errors as absence. A timeout stored as "not found" hides recovery for a full TTL. Store definitive not-found answers with a short TTL, and failures, if at all, as a distinct entry that lives seconds.
- Write-through partial failure. The database commit succeeds and the cache write fails, leaving the old value cached. Delete the key on any cache write error and rely on TTL as the backstop.
Leadership lens
- Require every write-behind design to state its durability window in seconds and the data that may be lost, and get the data owner to sign off.
- Choose strategies per data type, not per system. One service can rightly use write-behind for presence and write-around for payments.
- Ask what the pending queue does during a one-hour database outage.
- Prefer shared read-through libraries to hand-rolled cache-aside, so coalescing and negative caching are written and reviewed once.
Check yourself
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.
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.
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.
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
- RFC 9520: Negative Caching of DNS Resolution Failures (2023): the rules for caching failures separately from "does not exist", with a 1-second floor and 5-minute ceiling.
- Nishtala et al., Scaling Memcache at Facebook (NSDI 2013): look-aside caching with delete-on-write at very large scale, and why leases were needed.
Systems docs
- DynamoDB Accelerator: consistency models: a real write-through cache, its separate query cache, negative entries and write-around.
- NGINX proxy module:
proxy_cache_lock,proxy_cache_background_updateandproxy_cache_validas read-path policies.
Books
- Understanding Distributed Systems (2nd ed.), Roberto Vitillo, chapter "Caching": policies, local versus external caches, and what happens when the cache fails.
- Systems Performance (2nd ed.), Brendan Gregg, chapter 8 "File Systems": the page cache, write-back and what
fsynccosts. - Building Microservices (2nd ed.), Sam Newman, chapter 13 "Scaling": where to cache in a service architecture and how freshness is traded for load.
Other curricula
- donnemartin/system-design-primer: lists cache-aside, write-through, write-behind and refresh-ahead with their disadvantages; it does not cover negative caching or flush ordering.
- Caching guidance (Azure Architecture Center): frames strategy as private versus shared caches, seeding, expiration and optimistic or pessimistic concurrency.
- Caching challenges and strategies (AWS Builders' Library): treats the cache as a source of modal behaviour, with soft and hard TTLs and negative caching of errors.
Watch
- Caching in distributed systems: A friendly introduction (Gaurav Sen): write-through versus write-back and when each fits.
- Caching Strategies With Redis (Software With Shawn): cache-aside and write-through implemented against Redis, with their pitfalls.
Free resources
- Caching strategies (Amazon ElastiCache): lazy loading, write-through and TTLs with the trade-offs AWS sees in practice.
- Cache-Aside pattern (Azure Architecture Center): consistency issues and when cache-aside is the wrong choice.
- MapStore configuration guide (Hazelcast): real write-through and write-behind knobs, including write coalescing and batch size.
- Refresh (Caffeine wiki): how refresh-ahead serves old values while reloading asynchronously.
- Documentation for /proc/sys/vm (Linux kernel): the page cache's write-behind tunables and what each one controls.
- RFC 2308: Negative Caching of DNS Queries: the original, precise specification of caching "does not exist".