Devscape blog
All posts

cachingfundamentals

Caching strategies: cache-aside, write-through, and write-behind

Three ways to put a cache next to a database, what each one does when a write lands, and the failure each one is quietly signing you up for. With the invalidation problem stated honestly.

A cache is the cheapest performance win in any architecture and the most common source of bugs that only reproduce in production. Both facts come from the same place: a cache is a second copy of your data, and a second copy is something that can disagree with the first.

There are three patterns worth knowing by name. They differ in exactly one respect — what happens on a write — and each one buys speed with a different kind of risk. Knowing which risk you just accepted is the difference between "I'd put Redis in front of it" and an answer that survives a follow-up.

Cache-aside

The default, and what people usually mean when they say "we added a cache". The application talks to both stores and owns the coordination.

An app reads from Redis; on a miss it reads Postgres and then fills the cache.
Cache-aside: the cache knows nothing about the database. The application does all the work, which is exactly why it is the easiest one to reason about.

On a read: look in the cache, and on a miss read the database and put the result back. On a write: write the database, then delete the cached key.

async function getPaste(id) {
  const hit = await redis.get(`paste:${id}`);
  if (hit) return JSON.parse(hit);

  const row = await db.paste.findUnique({ where: { id } });
  if (row) await redis.set(`paste:${id}`, JSON.stringify(row), { EX: 3600 });
  return row;
}

async function updatePaste(id, patch) {
  const row = await db.paste.update({ where: { id }, data: patch });
  await redis.del(`paste:${id}`);   // delete, not overwrite — see below
  return row;
}

Why delete rather than update. Writing the new value into the cache looks tidier and is a race waiting to happen: two concurrent writers can interleave so that the newer database value is followed into the cache by the older one, and the cache then serves stale data until the TTL expires. Deleting is idempotent — the worst outcome of a duplicated delete is one extra miss.

What it gets right. The database stays the source of truth. A cache outage degrades you to slow rather than broken. Only data anybody actually asked for ever occupies memory.

What it costs you. Every miss pays both round trips. There is a window between the database write and the cache delete where readers get the old value. And it has one genuinely nasty failure mode:

Read-through is the same read path with the bookkeeping moved into a library or the cache itself, so the application just calls get. Same trade-offs, less code in your handlers, and the cache now needs to know how to load from your database.

Write-through

The write goes to the cache, and the cache writes it onward to the database before acknowledging.

An app writes to Redis, which writes through to Postgres and only then acknowledges.
Write-through: the cache sits in the write path. Consistency improves; write latency gets worse by exactly the amount the database takes.

What it gets right. The cache is never stale, because nothing reaches the database without passing through it. Read-after-write does the obvious thing — you read what you just wrote — and that alone kills a large family of bugs.

What it costs you. Every write now pays both hops before it can be acknowledged, so write latency goes up. And unless you pair it with a TTL, you fill memory with data that may never be read: write-through caches everything written, not everything wanted. On a write-heavy workload with cold reads, it is strictly worse than cache-aside.

Reach for it when writes are rare relative to reads and reading your own write immediately actually matters — a user profile, a settings blob, a document that one person edits and then looks at.

Write-behind

Acknowledge the write from the cache immediately, and flush to the database asynchronously — usually batched.

An app writes to Redis and is acknowledged at once; batched writes drain through a buffer to Postgres seconds later.
Write-behind: the fastest writes available, paid for with a window in which acknowledged data exists in exactly one place.

What it gets right. Writes are as fast as memory. Better, batching collapses work: a counter hit ten thousand times in a minute becomes one database update instead of ten thousand, which is the difference between a feasible design and an impossible one.

What it costs you. There is a window — usually seconds — in which the write has been acknowledged to the user and exists only in the cache. If that node dies, the data is gone, and you told the user it was saved. Replication and an append-only log narrow the window; nothing closes it.

That is a real trade and sometimes an obviously correct one. View counts, "last seen" timestamps, analytics events, likes: losing three seconds of them costs nothing and the write reduction is enormous. Payments and orders: never.

Which one, in one table

Write pathConsistencyFails by
Cache-asideDB, then invalidateBrief staleness after a writeStampedes; stale window
Write-throughCache to DB, then ackStrong for cached keysSlow writes; memory waste
Write-behindCache acks, DB laterEventualData loss on node failure

Most real systems are cache-aside, with write-behind on specific high-volume, low-value counters. If you say that in an interview you are describing what most production systems actually do.

The part that is actually hard

Choosing a pattern is the easy half. Invalidation is the half that generates incidents, and there are only three real tools:

TTL. Simple, bounded, and always correct in the limit — every key is at most one TTL stale. Add jitter. This handles more than people expect, and if the staleness bound is acceptable it is the whole answer.

Explicit invalidation. Delete on write. Precise, and it fails when the delete fails — a dropped connection between the database commit and the delete leaves a stale key until its TTL. Which is why you keep the TTL even when you invalidate explicitly: it is the backstop.

Versioned keys. Put a version in the key — paste:v7:123 — and a write bumps the version. Nothing is deleted; the old key ages out on its own. This is the one that survives multi-key and derived-data invalidation, where "which keys does this write affect?" has no simple answer.

Saying it well

The strongest version of a caching answer has four parts and takes about thirty seconds: what you cache, which pattern, what the TTL is, and what goes stale. "I'd cache the rendered timeline per user, cache-aside, 60-second TTL with jitter, so a new post can take up to a minute to appear for followers — and that's acceptable because it already does on every real feed."

That last clause is the one that separates candidates. Anybody can add a cache. The job is knowing what you just broke, and being ready to defend it.

Now design it yourself

Draw the architecture on a board and have it graded against the things an interviewer pushes on. Free, and no account needed.