Cache-Aside, Write-Through, Write-Behind
Three caching patterns, what each does on a miss and on a write, and why write-behind is disqualified for anything you must not lose.
GET key -> miss -> SELECT ... -> SET key value EX <ttl> // write path: UPDATE ...; DEL keyThese three names describe where a write goes. They differ only in what happens between the cache and the database, and picking one is a durability and consistency decision rather than a performance one.
On a miss, and on a write
| Pattern | Read miss | Write |
|---|---|---|
| Cache-aside (lazy loading) | App reads the database, then populates the cache | App writes the database, then deletes the key |
| Write-through | Same as cache-aside | App writes cache and database before acknowledging |
| Write-behind (write-back) | Same as cache-aside | App writes the cache, acknowledges, flushes to the database later |
What each one rules out
Write-behind acknowledges a write that exists only in memory. Lose the cache node and you lose those writes, so it is out for anything you would not be willing to recount: payments, inventory, audit trails. It is the right pattern for view counters and rate-limit buckets, where losing the last few hundred increments costs nothing.
Write-through pays both write latencies on every write and fills memory with data nobody reads, but it keeps the cache holding the latest value for everything that has been written. It still needs a miss path, because eviction and restarts happen regardless.
Cache-aside is the default because only data someone actually read is ever cached, and an empty cache is never a correctness problem.
Shorthand and longhand
SET key value EX 300 (the EX option, Redis 2.6.12) is the same write as SETEX key 300 value, which has been deprecated since that version, and the same as SET followed by EXPIRE key 300 — except that the two-command form costs an extra round trip and leaves the key immortal if the second command never lands.
Example
// Cache-aside: the cache is never the source of truth, and may be empty.
// node-redis v4+, Postgres via pg.
const TTL = 300; // seconds
export async function getUser(id) {
const key = `user:${id}`;
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit);
const { rows } = await pg.query(
'SELECT id, email, plan FROM users WHERE id = $1',
[id],
);
if (rows.length === 0) return null; // caching this miss: see sd-cache-failures
// These three are the same write, in descending order of preference:
// SET user:1 {...} EX 300 one round trip, atomic (Redis 2.6.12+)
// SETEX user:1 300 {...} identical, deprecated since 2.6.12
// SET user:1 {...}; EXPIRE user:1 300 two round trips; key is immortal
// if the second one fails
await redis.set(key, JSON.stringify(rows[0]), { EX: TTL });
return rows[0];
}
export async function updateUserPlan(id, plan) {
await pg.query('UPDATE users SET plan = $1 WHERE id = $2', [plan, id]);
// Delete, do not SET. A concurrent writer's value could otherwise win the
// cache while losing the database.
await redis.del(`user:${id}`);
}When to use it
- Adding a cache to a read-heavy endpoint without changing what the database guarantees.
- Deciding whether a counter can be acknowledged from Redis before it reaches Postgres.
- Explaining in an interview why you delete rather than update a key on write.
- Choosing a pattern for session data that must survive a cache restart.
More examples
Write-through for data that is always read back
Write-through earns its extra write latency when almost everything written is read again shortly afterwards, as sessions are. Note that it does not remove the miss path; anyone who claims write-through means the cache is always warm has forgotten eviction.
// One function owns both stores, and the order is fixed: durable store first.
async function putSession(id, session) {
await pg.query(
`INSERT INTO sessions (id, data, updated_at) VALUES ($1, $2, now())
ON CONFLICT (id) DO UPDATE SET data = $2, updated_at = now()`,
[id, session],
);
// If this SET fails, the next read misses and repopulates from Postgres.
// If the order were reversed, a crash in between would leave the cache
// holding a session the database has never heard of.
await redis.set(`session:${id}`, JSON.stringify(session), { EX: 1800 });
}
async function getSession(id) {
const hit = await redis.get(`session:${id}`);
if (hit !== null) return JSON.parse(hit);
// Still needed: eviction, failover and restarts all empty the cache.
const { rows } = await pg.query('SELECT data FROM sessions WHERE id = $1', [id]);
if (!rows.length) return null;
await redis.set(`session:${id}`, JSON.stringify(rows[0].data), { EX: 1800 });
return rows[0].data;
}Write-behind, and the size of the window you are accepting
Use write-behind only where the data is additive and approximate — view counts, like counts, metric buckets. The comment at the bottom is the test to apply: write down the worst-case loss in units a product owner understands, and see whether they accept it.
// Redis holds the authoritative count between flushes. Everything not yet
// flushed is lost if the node dies: that is the deal you are signing.
async function recordView(postId) {
const count = await redis.incr(`views:${postId}`);
// Flush every 100 increments. Worst case loss per post: 99 views.
if (count % 100 === 0) {
await pg.query('UPDATE posts SET views = $1 WHERE id = $2', [count, postId]);
}
}
// The same shape for a payment balance would be a defect: you cannot tell a
// customer their transfer was accepted and then lose it to an eviction.Read-through behind one accessor
Centralising the accessor is what makes invalidation tractable later — every key in the system has a known shape and a known owner. Reach for this as soon as a second endpoint starts caching the same entity, before the key formats drift apart.
// Read-through is cache-aside with the miss path hidden behind one function,
// so no caller can forget to populate or use the wrong TTL.
function cached(namespace, ttl, loader) {
return async (id) => {
const key = `${namespace}:${id}`;
const hit = await redis.get(key);
if (hit !== null) return JSON.parse(hit);
const value = await loader(id);
if (value !== null) await redis.set(key, JSON.stringify(value), { EX: ttl });
return value;
};
}
export const getProduct = cached('product', 600, (id) => db.product(id));
export const getPricing = cached('pricing', 30, (id) => db.pricing(id));
// Invalidation now has exactly one key format to target: `${namespace}:${id}`.
Discussion