How to buildOctober 11, 202613 min read
Software ArchitectureStep 8 of 10

Caching and invalidation

In short

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.

Deep diveLevel: AdvancedCaching

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, ETag and a 304 Not Modified response
  • 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

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
  1. 01Where to cache: from the browser to the database
  2. 02HTTP caching: Cache-Control, ETag and CDN
  3. 03Application cache strategies
  4. 04Invalidation: TTL, events and key versions
  5. 05Cache stampede: locking and stale-while-revalidate
  6. 06Consistency: what can be stale, and for how long
  7. 07How to measure the effect
  8. 08Exercises

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.

  1. 01BrowserThe user’s private cache: Cache-Control, ETag
  2. 02CDNA shared cache at the network edge: s-maxage, purge via API
  3. 03ApplicationL1 in process memory and L2 in Redis
  4. 04DatabaseBuffer cache and materialized views
The higher the layer, the cheaper the read and the harder it is to recall the copy

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.

src/http-server.ts
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'));
A catalogue API on node:http: Cache-Control for the browser and CDN, a strong ETag from a body hash, and a 304 response
terminal
$ 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"
Real curl output (the Date, Connection, Keep-Alive and Transfer-Encoding headers are removed): a repeat request with the ETag gets a 304 with no body
  • 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-cache with an ETag or Last-Modified, plus private if the page is personalised.
  • Personal data: private or no-store. A cookie alone does not make a response private; without private a 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.
  • Vary adds a request header to the cache key (Vary: Accept-Language). Vary: User-Agent all 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.

  1. TTL

    Sets how much staleness is acceptable: minutes for a catalogue price, seconds for an exchange rate, hours for a list of cities.

  2. 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.

  3. Key version

    A bulk change (repricing the catalogue, a new data format) is one INCR catalog:version command 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.

src/products.ts
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 };
}
An application instance: L1 in memory, L2 in Redis, invalidation by message and a catalogue version in the key
src/demo-invalidation.ts
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();
Two instances, a price change and a bulk update through the version
terminal
$ 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 db
Real output: after the price change both instances dropped L1; after INCR every product is read through new keys

Invalidation 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 5000 goes 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.
bash
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
Redis 8.10 in a container (bound to 127.0.0.1, --rm removes it on stop) and the project
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true
  },
  "include": ["src"]
}
For type checking only (npx tsc -p .): Node.js 24 runs .ts files directly
src/redis.ts
import { 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>>;
A node-redis 6.3 connection; connection errors are logged instead of crashing the process
src/db.ts
// 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;
}
A stand-in for a slow database: 200 ms per query and a call counter
src/cache.ts
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;
}
A stampede-proof cache: a rebuild lock, waiting for the result, and stale-while-revalidate
  • The lock token is random, and the lock is released with DELEX … IFEQ token (Redis 8.4+): if loading took longer than LOCK_MS and another request has taken the lock, we will not delete theirs. On Redis 7 the Lua script from the SET command documentation does the same; see the exercise.
  • LOCK_MS must exceed the slowest load, and WAIT_MS bounds the wait: if the rebuild hangs, the request goes to the database itself (fallback) instead of hanging.
  • The Redis documentation considers a simple SET NX lock 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.
src/demo-stampede.ts
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();
100 simultaneous requests for one key: unprotected, and on a cold, fresh and stale key
terminal
$ 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:695
Real output: 100 database calls unprotected, one with the lock; the stale key served in milliseconds, with one more call for the background rebuild

What 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: hit and stale against miss, waited and fallback
  • 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 fallback count: if it grows, rebuilds do not fit into WAIT_MS
  • Staleness: the time between a change and the moment the new value is seen
  • Redis memory and evicted keys (evicted_keys in INFO 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.