A cache is a copy of data kept closer to its consumer: in the browser, on a CDN, in process memory, in Redis or inside the database. The architectural decisions come down to three questions: which layer holds the copy, how it is filled (cache-aside, read-through, write-through) and how you learn it is outdated (TTL, a change event, a new key version). A separate danger is a cache stampede, when an expired hot key sends hundreds of identical requests to the database at once; a rebuild lock and serving the stale value during the refresh protect against it.
After this you will be able to
- choose a cache layer (browser, CDN, process memory, Redis, database) for a data type and its acceptable staleness
- configure HTTP caching for an API response:
Cache-Control,ETagand a304 Not Modifiedresponse - choose a strategy (cache-aside, read-through, write-through, write-behind) and an invalidation method: TTL, event or key version
- protect a hot key from a cache stampede with a rebuild lock and stale-while-revalidate, and verify the effect by measurement
Know beforehand
Verified with
Node.js 24 LTS (24.18)Redis 8.10.2 (redis:8.10-alpine)node-redis 6.3.0TypeScript 7.0HTTP caching (RFC 9111, RFC 5861)
Checked against docs: October 11, 2026
Contents
Where to cache: from the browser to the database
A user request passes through several layers, and each can store a ready response. The closer the copy is to the user, the cheaper it is to read and the harder it is to recall: the server cannot delete a response from a browser, a CDN copy goes only through the provider’s API, and a Redis key takes one command.
- 01BrowserThe user’s private cache: Cache-Control, ETag
- 02CDNA shared cache at the network edge: s-maxage, purge via API
- 03ApplicationL1 in process memory and L2 in Redis
- 04DatabaseBuffer cache and materialized views
Browser (private cache)
- What to store
- Static files with a hash in the name, public API responses
- How to invalidate
- Only by changing the URL or by expiry
- Main risk
- An old version cannot be recalled
CDN, reverse proxy (shared cache)
- What to store
- Public pages, static files, API responses that are the same for everyone
- How to invalidate
- Lifetime, purge through the provider’s API
- Main risk
- Serving one user’s data to another
Process memory (L1)
- What to store
- Hot reference data, configuration
- How to invalidate
- TTL, a change message
- Main risk
- Every instance has its own copy
Shared cache: Redis (L2)
- What to store
- Results of heavy queries, aggregates
- How to invalidate
- TTL, key deletion, key version
- Main risk
- Stampedes, drift from the database
Database
- What to store
- Page buffer cache, materialized views
- How to invalidate
- The DBMS itself; views by an explicit refresh
- Main risk
- A stale view between refreshes
Redis basics (data types, TTL, eviction under memory pressure and a basic cache-aside) are covered in the Redis caching lesson. This one is about the architectural decisions on top of those mechanisms: which layers to combine, how they learn about changes and what happens under load.
HTTP caching: Cache-Control, ETag and CDN
HTTP caching is defined by RFC 9111 and works without your code: response headers control it. MDN splits caches into private ones (a particular user’s browser) and shared ones (proxies, CDNs, reverse proxies). Without a Cache-Control header a cache may apply a heuristic, based on Last-Modified for instance, so MDN recommends an explicit Cache-Control on basically every response.
max-age=N
- Meaning
- The response is fresh for N seconds after it was generated
s-maxage=N
- Meaning
- The same for shared caches (CDN); private caches ignore it
no-cache
- Meaning
- May be stored, but must be validated with the server before every reuse. It does not mean “don’t cache”
no-store
- Meaning
- Do not store anywhere. Does not delete copies already stored
private
- Meaning
- Only the browser’s private cache, for personalised responses
public
- Meaning
- May go to a shared cache even if the request had
Authorization
immutable
- Meaning
- The response will not change while fresh, so skip revalidation on reload
stale-while-revalidate=N
- Meaning
- After freshness ends, serve the old copy for N more seconds while refreshing it in the background (RFC 5861)
When freshness runs out, a cache does not have to download the response again: it sends a conditional request with If-None-Match and the ETag value, a version identifier invented by the server. If the version is unchanged, the server answers 304 Not Modified with no body. Below is a catalogue endpoint: it reads the product through the application cache from the next sections and sets headers for the browser and the CDN.
import { createHash } from 'node:crypto';
import { createServer } from 'node:http';
import { createProductService } from './products.ts';
const products = await createProductService('http');
const server = createServer(async (req, res) => {
const match = req.url?.match(/^\/api\/products\/(\d+)$/);
if (req.method !== 'GET' || !match) {
res.writeHead(404).end();
return;
}
let value;
try {
({ value } = await products.getProduct(Number(match[1])));
} catch (err) {
console.error(err);
res.writeHead(500, { 'Cache-Control': 'no-store' }).end(); // never cache an error
return;
}
if (!value) {
res.writeHead(404, { 'Cache-Control': 'public, max-age=30' }).end();
return;
}
const body = JSON.stringify(value);
const etag = `"${createHash('sha256').update(body).digest('base64url').slice(0, 22)}"`;
const headers = {
// browser: 60 s; CDN: 300 s; after that a stale copy may be served for 10 min while revalidating
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=600',
ETag: etag,
};
// Conditional request: the client already has this version
const known = req.headers['if-none-match']?.split(',').map((t) => t.trim()) ?? [];
if (known.includes(etag)) {
res.writeHead(304, headers).end();
return;
}
res.writeHead(200, { ...headers, 'Content-Type': 'application/json' }).end(body);
});
server.listen(3080, '127.0.0.1', () => console.log('http://127.0.0.1:3080'));node:http: Cache-Control for the browser and CDN, a strong ETag from a body hash, and a 304 response$ curl -si localhost:3080/api/products/1
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
ETag: "QiAF3iaw-m9HY2CB31jgsu"
Content-Type: application/json
{"id":1,"title":"Latte","priceKzt":1800}
$ curl -si localhost:3080/api/products/1 -H 'If-None-Match: "QiAF3iaw-m9HY2CB31jgsu"'
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=600
ETag: "QiAF3iaw-m9HY2CB31jgsu"- Static files with a hash in the name (
app.3f9a1c.js):public, max-age=31536000, immutable. A new version gets a new URL, so the old one never needs recalling. - HTML pages cannot be renamed on every change, so MDN recommends
no-cachewith anETagorLast-Modified, plusprivateif the page is personalised. - Personal data:
privateorno-store. A cookie alone does not make a response private; withoutprivatea shared cache may store someone else’s profile. - Errors:
no-store, as in the handler above. A 500 cached on a CDN outlives the incident itself. Varyadds a request header to the cache key (Vary: Accept-Language).Vary: User-Agentall but kills hits, because that header has too many variations.
Application cache strategies
Inside an application, a cache is filled and updated using one of several schemes. They differ in who talks to the database, the application code or the cache itself, and in when the copy is updated on a write.
Cache-aside
- Read
- The application checks the cache, reads the database on a miss and stores the result
- Write
- Writes to the database, then deletes the key
- Trade-off
- Simple and survives a cache outage; a short window of stale data after a write
Read-through
- Read
- The application reads only the cache; on a miss the cache loads the data itself
- Write
- As in cache-aside
- Trade-off
- Loading logic in one place; needs a cache or library with a loader
Write-through
- Read
- As in read-through
- Write
- Writes go through the cache, which updates both the database and the copy in one operation
- Trade-off
- Fresh data right after a write; slower writes, and the cache holds data nobody may read
Write-behind (write-back)
- Read
- As in read-through
- Write
- Write to the cache, to the database later in batches
- Trade-off
- Fast writes and less database load; unsaved changes are lost if the cache fails
Microsoft’s guidance on the cache-aside pattern spells out the key caveats. It does not guarantee consistency between the cache and the store: another process can change the data, and the cache only learns about it when the entry expires. The lifetime should match the access pattern: too short and the cache keeps reloading, too long and the data goes stale. Order matters on writes: update the store first, then delete the key, or a concurrent request may put the old value back into the cache.
Invalidation: TTL, events and key versions
There are three ways to learn that a copy is outdated, and a production system combines them. A TTL is an upper bound on staleness, a safety net for when the other mechanisms misfire. Event-based invalidation removes the copy the moment the data changes: fast, but every change must be visible. Key versioning changes a version number in the key name instead of deleting many keys, so all old copies become unreachable at once and expire by TTL.
TTL
Sets how much staleness is acceptable: minutes for a catalogue price, seconds for an exchange rate, hours for a list of cities.
Event
A change in the database produces a “product changed” message, and a handler deletes the key. A reliable source of such events is the transactional outbox from the lesson on events and queues.
Key version
A bulk change (repricing the catalogue, a new data format) is one
INCR catalog:versioncommand instead of deleting thousands of keys.
With several application instances, each with an in-memory cache (L1) on top of shared Redis (L2), deleting the Redis key is not enough: copies remain in the memory of the other processes. Microsoft’s guidance notes exactly this: every instance has its own local cache, and they quickly diverge. Below, each instance subscribes to a Redis Pub/Sub channel and drops the key from L1 when a message arrives. Project setup and the cache module are in the stampede section; here is the product service built on top of them.
import { createCache } from './cache.ts';
import { loadProduct, savePrice, type Product } from './db.ts';
import { connectRedis } from './redis.ts';
const CHANNEL = 'cache:invalidate';
const L1_TTL_MS = 30_000; // safety net: Pub/Sub may lose a message
const L1_MAX = 10_000;
const policy = { freshMs: 60_000, staleMs: 300_000 };
// One instance of the application: L1 in process memory, L2 in shared Redis
export async function createProductService(name: string) {
const redis = await connectRedis();
const cache = createCache(redis);
const local = new Map<string, { value: Product | null; expires: number }>();
// A subscribed connection cannot run other commands, so it is a separate one
const sub = redis.duplicate();
sub.on('error', (err) => console.error(`[${name}] sub error:`, err.message));
sub.on('ready', () => local.clear()); // after a reconnect, missed messages are gone: drop L1
await sub.connect();
await sub.subscribe(CHANNEL, (key) => {
local.delete(key);
console.log(`[${name}] invalidated ${key}`);
});
// Bumping catalog:version switches every product to new keys at once
async function keyFor(id: number) {
const version = (await redis.get('catalog:version')) ?? '0';
return `catalog:v${version}:product:${id}`;
}
async function getProduct(id: number) {
const key = await keyFor(id);
const l1 = local.get(key);
if (l1 && l1.expires > Date.now()) return { key, value: l1.value, from: 'L1' };
const { value, outcome } = await cache.get(key, () => loadProduct(id), policy);
if (local.size >= L1_MAX) local.delete(local.keys().next().value!); // evict the oldest
local.set(key, { value, expires: Date.now() + L1_TTL_MS });
return { key, value, from: outcome === 'miss' ? 'db' : `L2 (${outcome})` };
}
async function changePrice(id: number, priceKzt: number) {
await savePrice(id, priceKzt); // 1. the source of truth first
const key = await keyFor(id);
await redis.del(key); // 2. the shared cache
await redis.publish(CHANNEL, key); // 3. local caches of every instance
}
async function close() {
await sub.close();
await redis.close();
}
return { getProduct, changePrice, redis, close };
}import { setTimeout as sleep } from 'node:timers/promises';
import { createProductService } from './products.ts';
// Two application instances; in production they are separate processes
const a = await createProductService('A');
const b = await createProductService('B');
async function show(name: string, svc: typeof a, id: number) {
const { key, value, from } = await svc.getProduct(id);
console.log(`${name} get ${key}: ${value?.priceKzt} KZT from ${from}`);
}
await show('A', a, 1);
await show('B', b, 1);
await show('A', a, 1);
await show('B', b, 1);
console.log('--- A changes the price of product 1');
await a.changePrice(1, 2000);
await sleep(50); // let the Pub/Sub message arrive
await show('B', b, 1);
await show('A', a, 1);
console.log('--- bulk price update: bump catalog:version');
await show('A', a, 2);
console.log('catalog:version =', await a.redis.incr('catalog:version'));
await show('A', a, 2);
await show('B', b, 1);
await a.close();
await b.close();$ node src/demo-invalidation.ts
A get catalog:v0:product:1: 1800 KZT from db
B get catalog:v0:product:1: 1800 KZT from L2 (hit)
A get catalog:v0:product:1: 1800 KZT from L1
B get catalog:v0:product:1: 1800 KZT from L1
--- A changes the price of product 1
[B] invalidated catalog:v0:product:1
[A] invalidated catalog:v0:product:1
B get catalog:v0:product:1: 2000 KZT from db
A get catalog:v0:product:1: 2000 KZT from L2 (hit)
--- bulk price update: bump catalog:version
A get catalog:v0:product:2: 2400 KZT from db
catalog:version = 1
A get catalog:v1:product:2: 2400 KZT from db
B get catalog:v1:product:1: 2000 KZT from dbINCR every product is read through new keysInvalidation also has races. The classic one for cache-aside: request A misses and reads the old price from the database; meanwhile request B writes the new price and deletes the key; then A puts the old value into the cache, where it lives until the TTL ends. The Redis client-side caching reference describes a similar race between a reply and an invalidation message. They cannot be removed entirely without extra machinery (a record version inside the value, refusing to write when the database version is newer), so a TTL stays mandatory even with event-based invalidation.
Redis can also send invalidations itself: in tracking mode the server remembers which keys a client has read and notifies it when they change, and node-redis supports this with the clientSideCache option over the RESP3 protocol. We did not run this mode in our check; the Pub/Sub design above shows the mechanism more clearly and does not depend on the client library.
Cache stampede: locking and stale-while-revalidate
A cache stampede (also called dog-piling) happens when a hot key expires and every request arriving while it is being rebuilt goes to the database at once. A hundred identical heavy queries in one second can bring a database down, after which the cache never gets filled at all. There are two defences, and they complement each other.
- A rebuild lock: only the request that acquires the lock key
SET lock:<key> <token> NX PX 5000goes to the database; the rest wait until a value appears in the cache. - Stale-while-revalidate: the key lives longer than it stays fresh. The freshness deadline is stored inside the value; once it has passed, the request immediately gets the old value while the rebuild runs in the background, also under the lock. The user never waits for the database.
docker run -d --rm --name kb-redis-cache -p 127.0.0.1:56390:6379 redis:8.10-alpine
mkdir cache-demo && cd cache-demo
npm init -y && npm pkg set type=module
npm install redis@6.3.0
npm install -D typescript@7.0.2 @types/node@24{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src"]
}npx tsc -p .): Node.js 24 runs .ts files directlyimport { createClient } from 'redis';
export async function connectRedis() {
const client = createClient({ url: process.env.REDIS_URL ?? 'redis://localhost:56390' });
client.on('error', (err) => console.error('redis error:', err.message));
await client.connect();
return client;
}
export type Redis = Awaited<ReturnType<typeof connectRedis>>;// Stand-in for a slow database: in a real project this is SQL or an external API
export type Product = { id: number; title: string; priceKzt: number };
const products = new Map<number, Product>([
[1, { id: 1, title: 'Latte', priceKzt: 1800 }],
[2, { id: 2, title: 'Cheesecake', priceKzt: 2400 }],
]);
export const db = { calls: 0 };
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function loadProduct(id: number): Promise<Product | null> {
db.calls++;
await sleep(200); // a heavy query
const product = products.get(id);
return product ? structuredClone(product) : null;
}
export async function savePrice(id: number, priceKzt: number): Promise<void> {
await sleep(10);
const product = products.get(id);
if (!product) throw new Error(`product ${id} not found`);
product.priceKzt = priceKzt;
}import { randomUUID } from 'node:crypto';
import { setTimeout as sleep } from 'node:timers/promises';
import type { Redis } from './redis.ts';
export type Outcome = 'hit' | 'stale' | 'miss' | 'waited' | 'fallback';
type Entry<T> = { value: T; freshUntil: number }; // freshness lives inside the value
type Policy = { freshMs: number; staleMs: number };
const LOCK_MS = 5000; // longer than the slowest load, otherwise two loads may overlap
const POLL_MS = 25;
const WAIT_MS = 2000;
export function createCache(redis: Redis) {
async function write<T>(key: string, value: T, { freshMs, staleMs }: Policy) {
const entry: Entry<T> = { value, freshUntil: Date.now() + freshMs };
// the key lives longer than it is fresh: the stale tail is served while rebuilding
await redis.set(key, JSON.stringify(entry), {
expiration: { type: 'PX', value: freshMs + staleMs },
});
}
// Only the request that takes the lock goes to the source
async function rebuild<T>(key: string, load: () => Promise<T>, policy: Policy) {
const lockKey = `lock:${key}`;
const token = randomUUID();
const locked = await redis.set(lockKey, token, {
condition: 'NX',
expiration: { type: 'PX', value: LOCK_MS },
});
if (locked !== 'OK') return null; // somebody else is already rebuilding
try {
const value = await load();
await write(key, value, policy);
return { value };
} finally {
// Redis 8.4+: delete the lock only if it is still ours
await redis.delEx(lockKey, { condition: 'IFEQ', matchValue: token });
}
}
async function get<T>(key: string, load: () => Promise<T>, policy: Policy): Promise<{ value: T; outcome: Outcome }> {
const raw = await redis.get(key);
if (raw !== null) {
const entry = JSON.parse(raw) as Entry<T>;
if (Date.now() < entry.freshUntil) return { value: entry.value, outcome: 'hit' };
// stale-while-revalidate: answer with the old value, rebuild in the background
rebuild(key, load, policy).catch((err) => console.warn('background rebuild failed:', err.message));
return { value: entry.value, outcome: 'stale' };
}
const built = await rebuild(key, load, policy);
if (built) return { value: built.value, outcome: 'miss' };
// Someone holds the lock: wait for their result instead of hitting the database
for (let waited = 0; waited < WAIT_MS; waited += POLL_MS) {
await sleep(POLL_MS);
const ready = await redis.get(key);
if (ready !== null) return { value: (JSON.parse(ready) as Entry<T>).value, outcome: 'waited' };
}
return { value: await load(), outcome: 'fallback' }; // the rebuild is too slow: go to the source
}
return { get };
}
// For comparison: plain cache-aside without any protection
export async function naiveGet<T>(redis: Redis, key: string, load: () => Promise<T>, ttlMs: number) {
const raw = await redis.get(key);
if (raw !== null) return JSON.parse(raw) as T;
const value = await load();
await redis.set(key, JSON.stringify(value), { expiration: { type: 'PX', value: ttlMs } });
return value;
}- The lock token is random, and the lock is released with
DELEX … IFEQ token(Redis 8.4+): if loading took longer thanLOCK_MSand another request has taken the lock, we will not delete theirs. On Redis 7 the Lua script from theSETcommand documentation does the same; see the exercise. LOCK_MSmust exceed the slowest load, andWAIT_MSbounds the wait: if the rebuild hangs, the request goes to the database itself (fallback) instead of hanging.- The Redis documentation considers a simple
SET NXlock insufficient where strict mutual exclusion matters and suggests the Redlock algorithm. For a cache this is not critical: the worst case is two loads instead of one, and no data gets corrupted. - A missing product is cached too (
null), so repeated requests for non-existent ids do not hammer the database.
import { setTimeout as sleep } from 'node:timers/promises';
import { createCache, naiveGet, type Outcome } from './cache.ts';
import { db, loadProduct } from './db.ts';
import { connectRedis } from './redis.ts';
const redis = await connectRedis();
const cache = createCache(redis);
const policy = { freshMs: 1000, staleMs: 60_000 };
const N = 100; // simultaneous requests for the same popular key
async function burst(label: string, request: () => Promise<Outcome | void>) {
const before = db.calls;
const t0 = performance.now();
const outcomes = await Promise.all(Array.from({ length: N }, request));
const ms = Math.round(performance.now() - t0);
const counts: Record<string, number> = {};
for (const o of outcomes) if (o) counts[o] = (counts[o] ?? 0) + 1;
console.log(`${label}: db +${db.calls - before} (total ${db.calls}), ${ms} ms`, Object.keys(counts).length ? counts : '');
}
await redis.del(['naive:product:1', 'product:1']);
await burst('naive, cold key ', async () => {
await naiveGet(redis, 'naive:product:1', () => loadProduct(1), 60_000);
});
await burst('locked, cold key ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await burst('locked, fresh key ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await sleep(1100); // the entry is now stale but still in Redis
await burst('locked, stale key ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await sleep(300); // the background rebuild has finished
await burst('after rebuild ', async () => (await cache.get('product:1', () => loadProduct(1), policy)).outcome);
await redis.close();$ node src/demo-stampede.ts
naive, cold key : db +100 (total 100), 212 ms
locked, cold key : db +1 (total 101), 210 ms { miss: 1, waited: 99 }
locked, fresh key : db +0 (total 101), 1 ms { hit: 100 }
locked, stale key : db +0 (total 101), 7 ms { stale: 100 }
after rebuild : db +0 (total 102), 2 ms { hit: 100 }
$ docker exec kb-redis-cache redis-cli INFO stats | grep keyspace_
keyspace_hits:401
keyspace_misses:695What the output shows. Unprotected, a hundred requests made a hundred database calls. With the lock, one request rebuilt the key (miss) and 99 waited for its result (waited): about the same response time, but the database got one query instead of a hundred. All hundred requests for the stale key got an answer at once (stale), and the single background rebuild shows up in the final counter: 102 calls instead of 101.
Consistency: what can be stale, and for how long
Any cache is a decision that “slightly outdated data is fine here”. Make it explicit for every data type: how many seconds of staleness are acceptable, and who gets hurt if it is more.
Caches well
- Catalogue, prices, schedules: read often, changed rarely
- Reference data and settings
- Dashboard aggregates where a minute of delay does not matter
- Static files with a version in the file name
Cache with care, or not at all
- Stock levels and booking slots: a stale value leads to double selling
- Balances and payment status: the database is the only source of truth
- Personal data in a shared cache: a leak risk
- Data that changes more often than it is read
A common user complaint is “I saved it, but I still see the old version”. That is a violation of read-your-writes consistency. The fixes: after a write, serve the author from the database, bypassing the cache; update the cache in the same request (write-through); or return the new value in the write response so the interface does not re-read it. Check stock, bookings and money in the database at the moment of the operation, even if the storefront shows a cached value; protection against double booking is covered in the lesson on transactions.
In Next.js the data cache is built into the framework: the use cache directive, cacheLife lifetime profiles, and cacheTag tags with updateTag and revalidateTag for event-based invalidation. It is the same set of ideas (TTL, stale-while-revalidate, invalidation by event) at the framework level. Details are in the lesson on server components and data fetching.
How to measure the effect
A cache without metrics breeds guesswork: you cannot tell whether it helps or where stale data appears. Collect metrics at the application level, from the outcomes the cache returns.
- Hit ratio per key type:
hitandstaleagainstmiss,waitedandfallback - p50 and p95 response times with and without the cache: an average hides slow misses
- Database load: query count and the time of the heaviest queries, which is why the cache exists
- The
fallbackcount: if it grows, rebuilds do not fit intoWAIT_MS - Staleness: the time between a change and the moment the new value is seen
- Redis memory and evicted keys (
evicted_keysinINFO stats)
Redis’s own counters are useful but need careful reading. After the demo above, INFO stats reported 401 hits and 695 misses, although from the application’s point of view one of the 400 protected requests went to the database. Redis counts every GET of a missing key as a miss: the 100 unprotected requests and the repeated checks of the 99 waiting requests polling the key every 25 ms. So keyspace_hits and keyspace_misses are fine for watching the server in general, but make caching decisions from application metrics.
Start caching with “how stale can this data be”, not “where do we get Redis”. The answer decides the layer, the TTL and the invalidation method.
Exercises
Next comes the system design walkthrough, where caching is one decision alongside queues and database replicas. The reliable source of events for invalidation is the transactional outbox from the lesson on events and queues.
How this was verified3 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.