Short answers to what readers ask most about this topic.
01What is the difference between cache-aside and write-through?
In cache-aside the application loads data into the cache on a miss and deletes the entry when it writes the database, so the cache can be stale until the next read. In write-through every write updates both the database and the cache, so readers see the new value right away. The price is an extra cache write on every update and a failure case when that second write fails.
02Is read-through the same as cache-aside?
They follow the same read flow, but the loading logic lives in different places. With cache-aside your application code checks the cache and queries the database on a miss. With read-through the cache layer does that itself, so callers only ever talk to the cache.
03When should I use write-back caching?
Use write-back for write-heavy data that can lose a few seconds of updates, such as view counters or presence flags. Repeated updates to the same key collapse into one database write at flush time. Avoid it for money, stock or audit data, because the cache holds the only copy until the flush completes.
04What is write-around caching and when does it help?
Write-around sends writes straight to the database and skips the cache, so the cache is filled only when a reader asks for the data. It helps for records that are written once and read rarely, like invoices or audit rows, because they do not push useful entries out of a small cache. The first read after each write is always a miss.
05Which caching pattern is the best default?
Cache-aside with database-first invalidation and a TTL is the safest default for read-heavy data. It survives a dead or empty cache, needs no special infrastructure, and bounds staleness with the expiry. Add write-through only for keys where readers must see their own writes immediately.
Cache-Aside vs Write-Through vs Write-Back: Which Fits?
Cache-aside, read-through, write-through, write-back and write-around compared: how each reads and writes, how each fails, with TypeScript and Redis code.
Use cache-aside as the default: the application reads the cache, loads from the database on a miss, and invalidates the entry on write. Choose write-through when readers need fresh values right after a write, write-back only for loss-tolerant write-heavy counters, and write-around for data that is written once and rarely read.
Picture a carwash POS where the service price list is read on every ticket and edited maybe twice a month. Putting Redis in front of Postgres is the easy decision. The hard one is the sentence nobody writes down: who puts the data into the cache, and what happens to the cache when someone edits a price.
This post compares the five patterns that answer that question: cache-aside, read-through, write-through, write-back and write-around. Each gets a TypeScript snippet using ioredis, a failure mode, and a place where it fits. Behaviour claims come from the Azure Architecture Center, the Amazon ElastiCache guide, Wikipedia and the Redis docs listed at the end. Arithmetic in the examples is illustrative, not measured.
What is the cache-aside pattern and how does it work?
In cache-aside the application owns the cache logic. It reads the cache first, on a miss it reads the database and stores the result, and on a write it commits to the database and then invalidates the cached entry. The Azure Architecture Center describes it as loading data into the cache on demand. Here it is in TypeScript with a TTL as the backstop.
import Redis from "ioredis";
const redis = new Redis(); // one long-lived client, never one per request
const TTL_SECONDS = 300; // the backstop: no entry stays wrong for longer
const key = (id: string) => "service:" + id;
export async function getService(id: string): Promise<Service | null> {
const hit = await redis.get(key(id));
if (hit !== null) return JSON.parse(hit) as Service; // cache hit
const row = await db.findService(id); // cache miss: 3 trips
if (row) {
// SET key value EX seconds: the value and its expiry in one command.
await redis.set(key(id), JSON.stringify(row), "EX", TTL_SECONDS);
}
return row;
}
export async function updateService(id: string, patch: Partial<Service>) {
// Wrong: await redis.del(key(id)) first. A reader can slip in between the
// delete and the commit, miss, reload the OLD row and re-cache it.
// Right: commit to the database first, then invalidate.
await db.updateService(id, patch);
await redis.del(key(id));
}
Two details carry the weight. The order on write matters: the Azure documentation says to update the data store before removing the cached item, because deleting first lets a concurrent reader reload the old row and put it back. And a miss costs three trips, as the ElastiCache guide counts them: the cache request, the database query and the write to the cache. Cache-aside is also tolerant of a dead cache, because a cold or replaced node only means more misses, not an outage.
How is read-through different from cache-aside?
Read-through has the same read flow, but the loading logic moves out of the caller and into the cache layer. The Azure documentation notes that many commercial caching systems provide read-through natively and that cache-aside is how an application emulates it when the cache does not. With Redis you get no native loader, so a read-through is a small class that wraps get, load and set behind one method.
// Read-through: the loader lives INSIDE the cache layer.
// Callers never see the database, so the miss logic exists exactly once.
export class ReadThroughCache<T> {
constructor(
private readonly redis: Redis,
private readonly prefix: string,
private readonly ttlSeconds: number,
private readonly loader: (id: string) => Promise<T | null>,
) {}
async get(id: string): Promise<T | null> {
const k = this.prefix + id;
const hit = await this.redis.get(k);
if (hit !== null) return JSON.parse(hit) as T;
const value = await this.loader(id);
if (value !== null) {
await this.redis.set(k, JSON.stringify(value), "EX", this.ttlSeconds);
}
return value;
}
}
const services = new ReadThroughCache<Service>(redis, "service:", 300, (id) =>
db.findService(id),
);
const svc = await services.get("svc_42"); // caller has no idea the DB exists
The benefit is that miss handling exists in one place instead of being copied into every service method. The cost is that the wrapper becomes a dependency every reader must go through, and it does nothing for writes. You still need to decide what a write does, which is the question the next three patterns answer.
What is write-through caching and when is it worth it?
Write-through updates the cache every time it writes the database. The ElastiCache guide lists the upside as data that is never stale, since the cache is refreshed on each write, and the price as two trips per write, one to the cache and one to the database. The snippet below keeps the database as the system of record and treats the cache write as the second step.
export async function saveService(s: Service): Promise<Service> {
// 1. The database is the system of record: it commits first, always.
const saved = await db.upsertService(s);
// 2. Then the cache is overwritten in the same request. Readers see the new
// value immediately instead of waiting for a miss.
try {
await redis.set(key(saved.id), JSON.stringify(saved), "EX", TTL_SECONDS);
} catch {
// The database commit already happened and cannot be undone. Drop the
// entry so a later read reloads it, and let the TTL cover the rest.
await redis.del(key(saved.id)).catch(() => undefined);
// Log it: a silent failure here is a stale cache nobody can explain.
}
return saved;
}
Use it where a reader must see the new value immediately after a successful write and reads vastly outnumber writes. The Azure write-through reference architecture says to apply it only to read-heavy access paths that need fresh values, and to read from the database directly for data that is rarely read after writes. The guide also warns of cache churn: most data is never read, so write-through fills the cache with entries nobody asks for unless a TTL trims them.
Write-through is not a transaction across two systems. If the database commits and the cache write fails, the cache is stale and the caller got an error or a lie. The Azure reference design answers with a durable outbox, a repair job and a TTL backstop. If you have none of those, treat the cache write as best effort and rely on invalidation plus TTL.
What is write-back caching and why is it risky?
Write-back, also called write-behind, writes only to the cache first and flushes to the database later. Wikipedia describes the same idea at the hardware level: initially writing is done only to the cache, and the data is written to the backing store later. The request path is as fast as Redis, and repeated updates to one key collapse into a single database write.
const DIRTY = "dirty:counter";
const FLUSH_MS = 10_000;
// The request path touches Redis only. MULTI makes the value and the
// dirty marker land together, so a counter is never changed but unmarked.
export async function bumpCounter(id: string, value: number) {
await redis.multi().hset("counter:" + id, "v", value).sadd(DIRTY, id).exec();
}
// A background loop writes dirty keys back to the database in batches.
export async function flushDirty() {
const ids = await redis.srandmember(DIRTY, 100); // peek, do NOT pop yet
for (const id of ids) {
const v = await redis.hget("counter:" + id, "v");
if (v === null) continue;
await db.setCounter(id, Number(v)); // if this throws, id stays dirty
await redis.srem(DIRTY, id); // forget it only after the DB commit
}
}
setInterval(() => flushDirty().catch(console.error), FLUSH_MS);
A worked example shows the gain. A counter updated 50 times in a minute, flushed every 10 seconds, produces at most 60 divided by 10 equals 6 database writes instead of 50. The same example shows the cost. A crash can lose everything dirty since the last flush, roughly 50 divided by 6 equals about 8 updates, because for that window the cache holds the only copy.
Write-back moves the source of truth into the cache until the flush completes. Never use it for money, stock movements or anything an auditor will ask about. Use it for counters, view tallies and presence data, where losing a few seconds of updates is acceptable, and make the flush loop remove a dirty marker only after the database commit.
When should I use write-around?
Write-around sends writes straight to the database and skips the cache, so a write never evicts or fills anything. Wikipedia calls the hardware equivalent no-write allocate: the data at the missed-write location is written directly to the backing store and not loaded into the cache. It pairs naturally with cache-aside reads, so a row enters the cache only when someone reads it.
// Write-around: the write goes to the database and the cache is not touched.
export async function createInvoice(inv: NewInvoice): Promise<Invoice> {
return db.insertInvoice(inv); // no redis call on purpose
}
// Reads still use cache-aside, with a short TTL, so an invoice only enters
// the cache if somebody actually asks for it.
export async function getInvoice(id: string): Promise<Invoice | null> {
const hit = await redis.get("invoice:" + id);
if (hit !== null) return JSON.parse(hit) as Invoice;
const row = await db.findInvoice(id);
if (row) await redis.set("invoice:" + id, JSON.stringify(row), "EX", 60);
return row;
}
This fits data that is written once and read rarely or much later, such as invoices, audit rows or import batches. It avoids polluting a small cache with entries that would be evicted before anyone reads them. The catch is the first read after a write, which is always a miss, so do not use it where that first read is the hot one.
Cache-aside vs write-through vs write-back: how do they compare?
The table puts all five side by side. The columns that matter in review are the write path, what goes wrong, and the kind of data each suits. Latency is described qualitatively on purpose, because the real numbers depend on your network and your database.
Pattern
Write path
Stale or lost data risk
Best fit
Cache-aside
Write the database, then delete the cache entry
Stale until the next miss or the TTL; no loss
Default for read-heavy data that tolerates brief staleness
Read-through
Not defined; pair it with another write pattern
Same as the write pattern chosen beside it
Many services sharing one loading rule
Write-through
Write the database, then overwrite the cache
Fresh after a success; stale if the cache write fails
Read-after-write on hot, read-heavy rows
Write-back
Write the cache, flush to the database later
Data written but not yet flushed is lost on a crash
Write-heavy counters and tallies that can lose seconds
Write-around
Write the database only; leave the cache alone
First read after a write is a guaranteed miss
Write-once, rarely-read records
Read the table as two independent choices: a read pattern (cache-aside or read-through) and a write pattern (invalidate, write-through, write-back or around). Most systems pair cache-aside reads with invalidate-on-write and add write-through only for the few keys where freshness is a requirement.
What are the consistency and failure modes of each pattern?
Every pattern is a different answer to what happens when two stores disagree, and each has a named failure. The Azure cache-aside page states plainly that the pattern does not guarantee consistency between the data store and the cache. These are the failures worth testing before production.
Cache-aside: a reader loads the old row between a stale delete and the commit, or an outside process edits the database directly and the cache never hears about it. The TTL is the only thing that bounds this.
Write-through: the database commit succeeds and the cache write fails, leaving a stale entry and a confusing error. A new or replaced cache node also starts empty, and the ElastiCache guide says to combine write-through with lazy loading to cover it.
Write-back: the cache node dies before the flush, and the unflushed writes are gone because no other copy exists. A flush that removes the dirty marker before the database commit makes the loss silent.
Write-around: the first read after every write misses, so a burst of fresh writes followed by reads behaves like an uncached system until the cache warms.
Redis down is a separate case. With cache-aside and write-through it should degrade to database reads, so wrap cache calls in a short timeout and treat any error as a miss. Write-back has no such fallback, which is another reason to keep it for data you can afford to lose.
Always give cached entries a TTL, even with write-through. Both the ElastiCache guide and the Azure reference design recommend an expiry as the backstop against partial failures and direct database edits, and Redis sets the value and expiry atomically with SET key value EX seconds.
Which caching pattern should I choose?
Decide per data type, not per application. A carwash ERP, for example, can reasonably run cache-aside for the service catalogue, write-through for a single shift-status key every terminal polls, and write-around for finished tickets. Work down this checklist for each kind of data.
Can this data be lost for a few seconds without anyone caring? If yes, write-back is possible. If it is money, stock or audit data, rule write-back out now.
Does a reader need the new value immediately after a successful write? If yes, use write-through, with a TTL and a plan for the cache-write failure.
Is the data written once and read rarely? Use write-around with cache-aside reads and a short TTL.
Otherwise use cache-aside with invalidate-on-write: database first, cache delete second, TTL always.
If three or more services repeat the same miss logic, wrap it in a read-through class so the rule lives once.
Whatever you pick, put the cache hit ratio on a dashboard. The arithmetic is simple: at 2,000 reads per second a 95 percent hit ratio sends 2,000 times 0.05 equals 100 reads per second to the database, while 80 percent sends 400. A pattern that cannot hold the ratio you planned for is the wrong one.
Treat caching as two separate decisions, a read pattern and a write pattern. Default to cache-aside with database-first invalidation and a TTL, add write-through only where read-after-write freshness is a stated requirement, keep write-back for data you can afford to lose, and reserve write-around for records that are written once and rarely read.