DEV Community

Cover image for Cache-Aside, Write-Through, Write-Behind: Six Caching Patterns, Two Decisions
Runsite Team
Runsite Team

Posted on Originally published at runsite.app

Cache-Aside, Write-Through, Write-Behind: Six Caching Patterns, Two Decisions

Cross-posted from the Runsite blog.

Cache-aside, read-through, write-through, write-behind, write-around, refresh-ahead. Put them in a list and they look like six competitors you're supposed to rank.

They don't compete. Each one answers one of two independent questions:

  1. When a value is missing from the cache, who goes to the database to get it?
  2. When data changes, who writes it to the database, and when?

Once you split the names along those two lines, the choice mostly makes itself.

Two axes

Pattern Who handles a read miss How a write reaches the database
Cache-aside Your application Your application writes, then deletes the key
Read-through The cache Not defined, pair it with a write pattern
Refresh-ahead The cache, in the background, before expiry Not defined
Write-through Not defined, usually paired with read-through The cache, synchronously, before your write returns
Write-behind Not defined, usually paired with read-through The cache, asynchronously, after your write returns
Write-around Your application Your application writes, the cache is left alone

Four of the six only cover one axis. Cache-aside and write-around are the two that cover both, and everything else gets combined: a read pattern plus a write pattern.

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

Under cache-aside your application runs both axes. On a read it asks the cache, and on a miss it queries the database and stores the result. On a write it updates the database and then removes the key. AWS docs call the read half "lazy loading", which is where a lot of people first see the other name.

It's the default for good reasons. Any key-value store works, since the store never needs to know your schema. And it fails gracefully: when the cache goes down, every read becomes a miss and the database carries the load it carried before anyone added a cache.

One side effect: the first read of every key is a miss by definition. Restart the cache and every key misses at once. No pattern on this list warms a cache for you.

Why the write path is a delete, not an update

The tempting write path is "update the database, then SET the new value in the cache". You save a round trip and the next reader finds a warm entry. It's also a race.

One slow read, one fast write:

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
Enter fullscreen mode Exit fullscreen mode

The reader fetched its row before the update and wrote it to the cache after. Now the cache says Anna, the database says Bea, and nothing will notice until the key expires. You serve a wrong value for the whole TTL.

Switching to DEL doesn't make the race impossible. A reader can still fill the cache after the delete with a row it read before the update. What changes is how long the damage lasts:

  • With SET on write, two concurrent writers can also collide. The writer whose database update landed first can be the one whose cache write lands last, and the cache keeps a value neither of them meant for a full TTL.
  • With DEL on write, the exposed window is the gap between one reader's query and its cache fill, usually microseconds. The next write clears the entry again instead of replacing it with another guess.

If even that window is too much for your data, a different pattern from the list won't fix it. You need a lock around the fill, a version number in the key, or to stop caching that value.

Database first, cache second

Order matters too. If you delete the key first and write the database second, you've opened a gap where the cache is empty and the database still holds the old row. Any read in that gap caches the old value, and it's back for a full TTL.

One more thing that bites in production: if the DEL fails because the cache blipped, the stale entry just stays. Most code swallows that error, because a cache failure shouldn't fail the request. That's right for the response. It's wrong for your logs. A failed invalidation is a correctness bug, and on data that matters it should be retried.

Read-through needs a cache that can reach your database

The only difference between cache-aside and read-through is who handles the miss. Under read-through, your code asks the cache and gets a value back without knowing whether it came from memory or from a query the cache ran on your behalf.

For that to work, the cache needs credentials for your database and a way to turn a key into a query. Key-value stores don't have that. It's a feature of data grids built around a loader interface: Hazelcast, NCache, Oracle Coherence and Redisson all expect you to implement a CacheLoader or something like it.

Plain Redis doesn't have one, and neither does Memcached. When a team says they "use read-through with Redis", they almost always mean a helper:

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;
}
Enter fullscreen mode Exit fullscreen mode

That's cache-aside with the miss logic in one place. Good idea, do it. Just don't go looking for the Redis config flag that turns on read-through, because there isn't one.

Write-through: consistency you pay for on every write

Write-through flips the write path. Your application writes to the cache, the cache writes to the database, and the call returns only after both succeed.

You get a cache that's never stale, because every write goes through it. You pay with an extra database hop on every write, one more component inside the write path that can fail, and a cache full of data nobody asked to read.

That last cost shows up late. Write-through caches everything that gets written. On an audit log or an events table, most of those rows will never be read again, and they push out the rows that would have been hits. Your hit rate drops and the memory bill doesn't.

It fits one kind of data well: values that must never be stale and change rarely. A settings table read on every request and updated twice a week is the textbook case. An orders table is the opposite.

On plain Redis you build it yourself, and the order is what matters:

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;
}
Enter fullscreen mode Exit fullscreen mode

These two calls aren't atomic. If the process dies between them, the cache lags behind the database. That's the safe direction, and it's still wrong. Swap the order and a failed database write leaves the cache holding a value that was never committed, which lives until the TTL runs out.

MULTI/EXEC won't save you here. A Redis transaction is atomic against other Redis clients, and PostgreSQL isn't part of it.

Write-behind: fast writes, and acknowledged data you can lose

Write-behind (also "write-back") is write-through with the database half made async. The cache acknowledges your write right away and updates the database later, usually in batches.

The speed gain is real. Writes return at cache speed, and a thousand increments to one counter collapse into a single database write.

The cost: for a while, data your user was told is saved exists only in the cache.

Write-through Write-behind
Write latency Cache plus database, every write Cache only
Data lost if the cache dies None Everything in the flush window
Database write volume One per app write Batched, repeated updates merge
Anything reading the DB directly Sees the write immediately Sees it after the flush; reports and replicas lag
Where a failure shows up In the request, you can return an error After you already returned success

The last row is the one that decides it. Write-behind moves failures to a point where you can't tell the user anymore.

Here's the usual shape, a buffer plus a flusher, on page view counts, where losing a few seconds costs nothing:

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

// Flusher: 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);
}
Enter fullscreen mode Exit fullscreen mode

Read the flusher as a list of ways to lose data. sPop removes the IDs before the database write succeeds, so a crash between those lines loses the updates. Pop after the write instead and you risk writing twice, which is fine for an idempotent upsert of an absolute count and wrong for an increment. And neither version survives the Redis process dying with unflushed data, unless persistence is on and the last append actually made it to disk.

Counters, view tallies and rate limit windows can live with that. A payment can't.

Why the canonical implementations are deprecated

Look up write-behind documentation and it reads like a museum. As of this writing, Redis's own write-behind recipe sits under a URL containing deprecated-features/gears-v1. Oracle Coherence 3.4 is where read-through, write-through, write-behind and refresh-ahead are documented together, which is how the four names ended up traveling as a set. After that comes IBM WebSphere eXtreme Scale, then NCache, Redisson and Hazelcast.

Write-behind as a cache feature comes from the enterprise data grid era, when the grid sat in front of the database and owned writes by design. Outside that setup, what you want already has a name: a queue, or an outbox table. Same mechanics, write accepted fast and applied later. The difference is that a queue is built to survive a restart and a cache is built to be thrown away.

My rule of thumb: if you catch yourself designing write-behind on top of Redis, check whether you're rebuilding a job queue with worse durability. For a counter flushed every ten seconds, you aren't. For almost anything else, you probably are.

Refresh-ahead

Refresh-ahead re-fetches a value shortly before its TTL expires, so a hot key never goes cold. It kills the latency spike you get every time a popular key expires. The catch is that refreshing keys nobody reads anymore keeps burning queries forever, so use it only on keys with a measured, steady read rate. On plain Redis, check TTL key on read and refresh outside the request path when it drops below a threshold. Randomize that threshold per key and you get stampede protection for free.

Write-around

Write-around is the simplest write path: write to the database, leave the cache alone, and let the stale entry expire. It's correct when the TTL is short enough that the staleness doesn't matter. It's also what plenty of production systems do by accident, because nobody ever wrote the invalidation call.

Done on purpose, it's underrated. A five-second TTL on a dashboard aggregate takes almost all the load off the database for a page that refreshes every second, and there's no invalidation logic to get wrong.

Choosing a pattern

If this describes your data Use Because
Mostly reads, a few seconds stale is fine Cache-aside with delete on write Nothing else buys enough to justify its cost
Mostly reads, must never be stale Write-through, or don't cache it A TTL is a decision about how wrong you're willing to be
Mostly writes Cache derived reads only, not the write path A cache on a write-heavy path adds cost per write and returns little
A counter or tally, losing seconds is survivable Write-behind, or a queue The one place write-behind's trade clearly pays
Rare writes, short TTL acceptable Write-around Invalidation you skip can't go wrong
One key read constantly, expensive to compute Refresh-ahead Cheaper than the stampede it prevents

Cache-aside is the honest answer to more of these rows than the table makes it look.

The part that ends up in your compliance paperwork

This almost never comes up in caching articles. A cache holding personal data is a place where personal data is stored, with the same residency and retention obligations as the database behind it. The pattern changes what that means:

  • With cache-aside or write-around, the cache holds a copy. Deleting the database row doesn't delete the copy, so an erasure request has to cover the cache explicitly. If it doesn't, your TTL is your retention policy, chosen by accident.
  • With write-through, the cache receives every write, including columns you'd never have picked to cache. If a table has a field you wouldn't put in a log file, write-through puts it in Redis anyway.
  • With write-behind, for the length of the flush window, the only copy of a committed write lives in the cache. If your database is in the EU and your cache isn't, then for those seconds your data isn't either.

None of this is hard to handle. The cache just needs to live in the same jurisdiction as the database and appear in the same processor records. It gets skipped because the cache is the component teams forget they run.

The short version

  • Six names, two questions: how a value gets into the cache on a miss, and how a write gets to the database.
  • Cache-aside fits most workloads. On write, update the database first and then DEL the key. Never SET it.
  • Plain Redis can't do read-through or write-through. Your get-or-fetch helper is cache-aside, which is fine.
  • Write-through is worth its extra hop for settings and feature flags, and not much else.
  • Write-behind can lose writes you already confirmed. Use it for counters; use a queue for anything a customer will ask about later.
  • The cache holds personal data too, so it needs the same residency answer as your database.

Related on the Runsite blog: when a Redis cache is worth adding at all, and what happens when it fills up: maxmemory and eviction policies.

Disclosure: I work on Runsite, an EU-hosted platform that runs managed Redis. Nothing above depends on it; every pattern here works the same on any Redis.

Top comments (0)