CachingAugust 24, 202613 min read

Cache-Aside, Write-Through, Write-Behind: Choosing a Caching Pattern

Six names for what is really two decisions. The axes people conflate, the race that makes cache-aside delete instead of update, and why every canonical write-behind implementation is deprecated.

RThe Runsite Team

Six names get handed out for what is really two decisions, and the names are most of the reason this topic feels harder than it is. Cache-aside, read-through, write-through, write-behind, write-around, refresh-ahead. Presented as a list, they look like six competing options you have to rank against each other.

They do not compete. They answer two independent questions: how a value gets into the cache when it is missing, and how a write gets to the database. Separate those and the choice mostly makes itself.

This article stays at the level of the decision. Whether a cache is worth adding at all is a different question with its own answer, and language-specific implementation belongs in the Runsite docs. What follows is how to pick, and what each choice costs you on the day it goes wrong.

Two axes, not one list

The read axis asks who handles a cache miss. Either your application notices the miss and goes to the database, or the cache does that for you. That is the entire difference between cache-aside and read-through.

The write axis asks who puts the new value in the database, and when. Your application can do it and then clear the cached copy, the cache can do it synchronously before your write returns, or the cache can do it later on its own schedule.

PatternWho handles a read missHow a write reaches the database
Cache-asideYour applicationYour application writes, then deletes the key
Read-throughThe cacheNot defined. Pair it with a write pattern
Refresh-aheadThe cache, in the background, before expiryNot defined. Pair it with a write pattern
Write-throughNot defined. Usually paired with read-throughThe cache, synchronously, before your write returns
Write-behindNot defined. Usually paired with read-throughThe cache, asynchronously, after your write returns
Write-aroundYour applicationYour application writes. The cache is not touched
Three of the six say nothing about writes and three say nothing about reads. They combine along the two axes rather than replacing one another.

Most systems in production are cache-aside on both axes, and most pages ranking for these terms present all six as a flat menu. The Azure Architecture Center entry for cache-aside, the top organic result in Germany as of writing, is careful about the distinction. Several of the pages beneath it are not.

Cache-aside: the default, and the line people get wrong

Cache-aside puts your application in charge of both axes. Read: ask the cache; on a miss, query the database and store the result. Write: update the database, then remove the key. The AWS whitepaper on database caching strategies calls the read half lazy loading, which is where most people first meet the alternative name.

It earns its position as the default. Any key-value store will do, because the store never has to know anything about your schema, and you decide key by key what is worth keeping. It also degrades honestly: when the cache is unavailable every read becomes a miss, and the database carries the load it used to carry before anyone added a cache.

One consequence is worth naming early: the first read of any key is a miss by definition, and on a container that has just been rebuilt, every key is a miss at once. That is the cold start problem wearing a different hat. A cache does not warm itself, and the pattern you choose does not change that.

Why the write path is a delete, not an update

The tempting version of the write path is: update the database, then write the same value into the cache. One round trip saved, and the next reader arrives to a warm entry. It is also a race, and the loser stays lost.

Two concurrent operations, one slow read and one fast write:

text
T1  reader   GET user:42                        -> miss
T2  reader   SELECT ... WHERE id = 42           -> reads "Anna"
T3  writer   UPDATE ... SET name = 'Bea' WHERE id = 42
T4  writer   SET user:42 "Bea"                  -> cache is correct
T5  reader   SET user:42 "Anna"                 -> the stale read wins

The reader fetched its value before the update and stored it afterwards. The cache now holds Anna, the database holds Bea, and nothing in the system will notice until the key expires. That is not a millisecond of skew. That is the full width of your TTL serving a value which was already wrong at the moment it was written.

Deleting the key instead of setting it does not eliminate the race, and anyone who says it does has not drawn the diagram: a reader can still fill the cache after the delete with a value it read before the update. What changes is how long the damage lasts.

  • With SET on write, two concurrent writers can also collide, and the writer whose database update landed first may be the one whose cache write lands last. The cache then holds a value that neither writer intended, for a full TTL, and the next write can produce the same outcome again.
  • With DEL on write, the exposed window is the gap between one reader's database query and its cache fill, which is typically microseconds. Every subsequent write clears the entry again rather than replacing it with another guess.

The narrower window is the whole argument. If your data cannot tolerate even that, the answer is not a different pattern from this list. It is a lock around the fill, a version number inside the key, or a decision not to cache that value at all.

Database first, cache second

Order matters as much as the operation. Delete the key first and write to the database second, and you have opened a window in which the cache is empty and the database still holds the old row. Any read landing in that window fetches the old value and caches it, and the stale entry is back for a full TTL. You created that window on purpose, and it is wider than the one you were trying to avoid.

The invalidation nobody retries

If the DEL fails because the cache is briefly unreachable, the entry stays stale and nothing comes back for it. Most implementations swallow that error, on the reasoning that a cache failure should never fail a request. That reasoning is right about the response and wrong about the logging: a failed invalidation is a correctness event, and on data that matters it deserves a retry rather than a shrug.

Cache-aside vs read-through

The difference is one line of ownership. Under cache-aside your application handles the miss. Under read-through the cache handles it, and your code calls the cache and receives a value without knowing whether it came from memory or from a database query issued behind the cache.

Read-through is the cleaner arrangement where you can have it, because the fill logic exists in exactly one place instead of at every call site. The catch is in what "the cache handles it" actually requires.

Read-through: what it actually requires

For a cache to fill itself on a miss, it has to reach your database, hold credentials for it, and know how to turn a key into a query. Key-value stores do not do this. It is a feature of caching products built around a loader interface, which is also where the terminology comes from.

Hazelcast, NCache, Oracle Coherence and Redisson all provide one, and all four expect you to implement a CacheLoader or its local equivalent. They are data grids or grid clients, sold on exactly this: the cache is a layer that owns access to the store behind it.

Plain Redis has no such interface. Neither does Memcached. What teams mean when they say they use read-through with Redis is almost always a helper function:

javascript
async function getUser(id) {
  const key = `user:v2:${id}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const row = await db.users.findById(id);
  await redis.set(key, JSON.stringify(row), { EX: 300 });
  return row;
}

That is cache-aside with the miss handling moved into one function. It is a good idea and you should do it. It is not read-through, and the distinction stops being pedantic the moment somebody goes looking for the configuration option that would enable a feature which does not exist.

Write-through: consistency you pay for on every write

Write-through inverts the write path. Your application writes to the cache, the cache writes to the database, and the call does not return until both have succeeded.

What you buy is a cache that is never stale. Every write passes through it, so there is no invalidation to forget and no window to reason about. What you pay is a database round trip added to every write, a second component that can fail inside the write path, and a cache steadily filling with data nobody has asked to read.

That last cost is the one people discover late. Write-through populates the cache with everything written, whether or not it will ever be read again. On a write-heavy table with a long tail, an audit log or an events table, you are spending memory on rows no request will want, and evicting the rows that would have been hits. The hit rate falls while the bill stays the same.

Write-through is worth its cost in one shape of workload: the value must never be read stale, and it is written rarely enough that the extra hop is invisible. A settings table read on every request and updated twice a week fits exactly. An orders table is the opposite shape and will punish you for it.

Write-through cache Redis: what plain Redis can and cannot do

The same limitation applies for the same reason. Redis will not write to PostgreSQL on your behalf. A write-through cache on managed Redis means your application writes to both, in order, and treats the pair as a single operation:

javascript
async function updateUser(id, patch) {
  // Source of truth first. If this throws, nothing else has happened.
  const row = await db.users.update(id, patch);

  await redis.set(`user:v2:${id}`, JSON.stringify(row), { EX: 300 });
  return row;
}

Two things are true about those five lines and both get missed. The pair is not atomic: the process can die between the statements, leaving the cache behind the database, which is the safe direction and still wrong. And reversing the order to write the cache first means a failed database write leaves the cache holding a value that was never committed, which is the unsafe direction and produces a phantom that survives until the TTL.

Redis transactions do not rescue this. MULTI and EXEC make a group of Redis commands atomic with respect to other Redis clients, and PostgreSQL is not inside that group.

Not this write-through

Around half the long tail for the phrase write through cache is about disk controllers rather than applications: assuming drive cache: write through, intel rst cache mode write through, raid 5 cache policy write back vs write through. Those describe whether a controller acknowledges a write before or after it reaches the disk. The idea has the same shape and none of the settings transfer. If a result mentions a battery-backed write cache, you have landed in the storage article.

Write-behind: the fastest writes and the acknowledged data you can lose

Write-behind, also called write-back, is write-through with the second half made asynchronous. Your application writes to the cache, the cache acknowledges immediately, and the database is updated some time later, usually in batches.

The performance case is real and it is large. Writes return at cache speed. A thousand updates to the same counter collapse into one database write. A burst that would have queued behind the database's write throughput is absorbed by memory instead.

The cost fits in one sentence: there is a window during which data your users have been told is saved exists only in the cache.

Write-through vs write-behind cache

Write-throughWrite-behind
Write latencyCache plus database, on every writeCache only
Data loss if the cache diesNone. The database already has itEverything inside the flush window
Database write volumeOne per application writeBatched, and repeated updates coalesce
Anything reading the database directlySees the write immediatelySees it after the flush. Reports, replicas and analytics all lag
Where a failure surfacesIn the request. You can return an errorAfter you already returned success
The last row decides it. Write-behind moves failure to a place where you can no longer tell the user about it.

Write behind caching example

The shape is a buffer plus a flusher. Page view counts are the canonical fit, because losing a few seconds of them costs nothing anyone will notice:

javascript
// Application write path: cache only, returns immediately.
await redis.hIncrBy("pageviews", articleId, 1);
await redis.sAdd("pageviews:dirty", articleId);

// Flusher: a separate process, every few seconds.
const dirty = await redis.sPop("pageviews:dirty", 500);
if (dirty.length) {
  const counts = await redis.hmGet("pageviews", dirty);
  await db.pageviews.bulkUpsert(dirty, counts);
}

Read the second block as a catalogue of failure modes rather than as a recipe. sPop removes the identifiers before the database write has succeeded, so a crash between the two statements loses those updates for good. Moving the pop after the write risks writing twice, which is harmless for an idempotent upsert of an absolute count and harmful for an increment. Neither version survives the cache process dying with unflushed data, unless persistence is enabled and the last append actually reached disk.

Which is the real boundary of the pattern. Counters, view tallies and rate limit windows tolerate the trade. Put a payment through it and you have built a system that says yes and then quietly forgets.

Why the canonical implementations are deprecated

Search for write-behind and the results page reads like a museum. As of writing, the first organic result in Germany is Redis's own write-behind recipe, at a URL containing deprecated-features/gears-v1. Third is Oracle Coherence 3.4, which documents read-through, write-through, write-behind and refresh-ahead together and is where the four names travel as a set. Eighth is IBM WebSphere eXtreme Scale 8.6.1. The remainder are NCache, Redisson and Hazelcast, which are the same category of product.

Ranking did not do that on its own. Write-behind as a cache feature belongs to the era of the enterprise data grid, where the grid sat in front of the database and owned the write path by design. Outside that architecture the thing you want already has a different name: a queue, an outbox table, or a stream consumer. The mechanics are identical, in that the write is accepted fast and applied to the database later. The difference is that a queue is built to survive a restart, and a cache is built to be thrown away.

So if you find yourself designing write-behind on top of a cache, the useful question is whether you are reinventing a job queue with weaker durability guarantees. Sometimes the answer is no, and a counter flushed every ten seconds is exactly the right amount of machinery. Often the answer is yes.

Refresh-ahead cache

Refresh-ahead is the read-path counterpart to write-behind: the cache re-fetches a value shortly before its TTL expires, so a popular key is never actually cold. Coherence documents it beside the other three, which is where most people encounter the name.

It solves the latency spike a hot key produces every time it expires, and it introduces a smaller problem in exchange, because refreshing keys nobody will ask for again burns database queries in the background indefinitely. Apply it to keys with a measured, steady read rate and to nothing else.

On plain Redis you approximate it in the application: read the remaining lifetime with TTL key, and when it drops below a threshold, refresh outside the request path. Randomise the threshold per key and the same mechanism doubles as stampede protection, which the pillar covers alongside the other failures that only appear under load.

Write-around, and the pattern with no name

Write-around is the simplest write path available: write to the database and leave the cache alone. No update, no invalidation. The stale entry expires on its own schedule.

It is correct when the TTL is short enough that the staleness is acceptable and writes are rare enough that most entries would never have been invalidated anyway. It is also what a considerable number of production systems are doing right now, whether or not anybody chose it, because the invalidation call was never written in the first place.

The variant that appears on none of these lists is the one where you skip cache writes entirely and lean on a deliberately short TTL. It sounds lazy and it is frequently the right answer: a five-second TTL on a dashboard aggregate removes almost all of the database load for a page that refreshes every second, and it has no invalidation logic to get wrong. Correctness you do not have to implement is the cheapest correctness there is.

Choosing a pattern

Read the left column as a situation rather than a product tier. Read paths and write paths still combine freely.

If this describes your dataUseBecause
Reads dominate and a few seconds of staleness is acceptableCache-aside with delete on writeThe default. Nothing else buys enough to justify its cost
Reads dominate and the value must never be read staleWrite-through, or do not cache itA TTL is a decision about how wrong you are prepared to be
Writes dominateCache derived reads only, not the write pathA cache on a write-heavy path adds cost per write and returns little
It is a counter or a tally, and losing a few seconds is survivableWrite-behind, or a queueThe one place write-behind's trade is clearly worth taking
Writes are rare and a short TTL is acceptableWrite-aroundThe invalidation you skip is invalidation you cannot get wrong
One key is read constantly and is expensive to recomputeRefresh-ahead, or probabilistic early refreshCheaper than the stampede it prevents
Six situations, and cache-aside is the honest answer to more of them than the length of this table suggests.

Two questions sit outside the table and both get asked at this point. No pattern here decides whether caching is the right answer to a slow application; that comes first and has its own decision frame. And if writes are slow because the database has run out of connections rather than out of throughput, the fix is a connection pooler in front of PostgreSQL, which no caching pattern substitutes for.

What each pattern does to your GDPR paperwork

This section is absent from every result on both of the pages that rank for these terms, as of writing, and it is not a technicality. A cache holding personal data is a place where personal data is stored, with the residency, retention and processor obligations that follow from that fact.

The write pattern changes the shape of the obligation in a way that is easy to miss:

  • Cache-aside and write-around. The cache holds a copy whose original lives in the database. Deleting the row does not delete the copy, so an erasure request has to name the cache explicitly. Otherwise the TTL is your retention policy, chosen by accident.
  • Write-through. Everything above, plus the cache now receives every write, including columns you would never have chosen to cache. If a table has a field you would not put in a log file, write-through puts it in the cache.
  • Write-behind. The strongest version. For the length of the flush window, the only copy of a committed write is in the cache. If your database is in the EU and your cache is not, then for those seconds neither is your data, and a processing record naming only the database is inaccurate rather than incomplete.

None of this is hard to satisfy. It needs the cache to sit in the same jurisdiction as the database, under the same processor agreement, named in the same documents. It gets skipped because the cache is the component teams forget they are running. Where your data physically sits, and why that is the question a regulator actually asks, applies to Redis exactly as it applies to PostgreSQL.

How Runsite handles it

Managed Redis for caches, sessions and job queues runs as a toggle beside your application rather than as a separate account with a separate vendor. It is Redis 7+ with TLS on by default as of writing, so the string you are handed is rediss:// rather than something you opt into afterwards.

On the patterns above the honest summary is that you get a plain, fast, correctly configured Redis, and the patterns are yours to implement. There is no CacheLoader interface, because there is no Redis with one. What the platform does decide is the part that determines whether write-behind is defensible at all.

  • Persistence. RDB snapshots from the €5 Starter plan, RDB plus AOF from Standard upwards. Write-behind on an instance with no append log means the flush window is lost on every restart, planned or otherwise.
  • Failover. Automatic from Standard, so a node failure does not quietly empty a buffer that was about to be flushed.
  • Location. Instances run in the Frankfurt, Germany region. Keys, sessions and any RDB or AOF files stay inside the EU, and the signed data processing agreement attached to every plan, the free one included, covers the cache and the database as one arrangement rather than two.

The full plan breakdown sits in the pillar rather than being repeated here, and most teams run the cache next to managed PostgreSQL hosted in the EU. The free 50 MB instance is enough to run a real pattern against real traffic and find out whether the hit rate justifies the second copy, before anyone pays for it. Choose the pattern first and size the instance second; doing it the other way round is how people end up running write-behind on a plan with no append log.

The short version

  • Six names, two questions. How a value gets into the cache on a miss, and how a write reaches the database. The patterns combine along those axes rather than competing.
  • Cache-aside is the default and is right for most workloads. Read: check the cache, miss, query, fill. Write: update the database, then delete the key.
  • Delete the key on write rather than updating it. Updating lets a slow concurrent read leave a stale value in place for a full TTL; deleting narrows the exposure to the gap between one reader's query and its fill.
  • Database first, cache second. Reversing the order creates a wider window than the one you were avoiding.
  • Read-through and write-through need a cache that can reach your database. Plain Redis and Memcached cannot. What gets called read-through on Redis is cache-aside inside a helper function, which is fine as long as everyone knows that is what it is.
  • Write-through never serves a stale value and adds a database round trip to every write. Worth it for values read constantly and written rarely, such as settings and feature flags.
  • Write-behind returns writes at cache speed and puts committed data somewhere that can lose it. Fine for counters and tallies. For anything a customer will ask about later, use a queue, which is the same idea built to survive a restart.
  • The canonical write-behind implementations are enterprise data grids. As of writing, Redis's own write-behind page sits under deprecated-features/gears-v1, and the results above and below it are Oracle Coherence 3.4 and WebSphere eXtreme Scale 8.6.1.
  • write through also names a RAID controller setting, and roughly half the search traffic for the phrase is about disk caches. None of those settings transfer.
  • Every pattern puts a copy of your data in the cache, and write-behind puts the only copy there for the length of the flush window. The cache needs the same residency answer and the same processor agreement as the database.
FAQ

Frequently Asked Questions

Common questions about this service.

Cache-aside, also known as lazy loading, is a caching pattern in which the application code, rather than the cache, coordinates between the cache and the database. On a read, the application asks the cache for a key; if the value is present it is returned immediately, and if it is missing the application queries the database, stores the result in the cache with a time to live, and returns it. On a write, the application updates the database and then deletes the cached key rather than overwriting it, so that the next read repopulates the entry from the current state of the database. It is the most widely used caching pattern because it works with any plain key-value store, it gives the application full control over what is cached, and it degrades safely: if the cache becomes unavailable, every read turns into a miss and the database serves the traffic it would have served without a cache.

In Redis the pattern is three commands and one rule. A read issues `GET key`; a nil reply is a miss, at which point the application queries the database and calls `SET key value EX <seconds>` to store the result with an expiry. A write updates the database first and then issues `DEL key`, so the stale entry disappears and the next reader rebuilds it. The rule is that the delete follows the database write rather than preceding it, because deleting first opens a window in which a concurrent read caches the old row again. Redis provides no built-in loader, so the miss handling lives in your application code; wrapping it in a single get-or-fetch helper is good practice, but it is still cache-aside rather than read-through. Two additions are worth making from the start: put a version prefix in the key, such as `user:v2:42`, so a schema change moves the whole namespace, and randomise the TTL by a few percent so that keys written together do not expire together.

They answer different questions and are frequently confused. TTL, or time to live, is an expiry you set on an individual key: after the specified number of seconds the key is removed whether or not memory is under pressure, and it is how you decide how stale a cached value is allowed to become. LRU, or least recently used, is an eviction policy that applies when the cache runs out of memory: Redis drops the keys that have gone longest without being accessed to make room for new ones, regardless of any TTL those keys carried. Redis approximates rather than implements true LRU, sampling a small number of candidate keys per eviction instead of maintaining a global ordering, which is cheaper and close enough in practice. So TTL is about correctness and freshness, and LRU is about capacity. A key can disappear for either reason. The important detail is that if no eviction policy is configured, a full cache does not evict at all; upstream Redis defaults `maxmemory-policy` to `noeviction`, which refuses new writes instead of making room.

The phrase means two different things depending on who is asking. In computer architecture the three types are the CPU cache levels: L1, L2 and L3, which sit between the processor and main memory and differ in size and access latency. In application architecture, which is the subject of this article, the useful three-way split is by location: an in-process cache held in your application's own memory, which is fastest but private to a single instance and lost on every restart; a distributed cache such as Redis or Memcached, reached over the network and shared by every instance, which survives deploys and is the reason most teams run one; and a client or edge cache, meaning browser caching and CDN caching, which keeps the request from reaching your servers at all. The three are complementary, and a well-tuned system usually runs all of them at once.

Your app deserves to be online

Free to start. Deploy in under a minute. No credit card needed.