Where Caches Live

The four caches between a user and your database, who owns each one, and which of them you can actually invalidate.

SyntaxCache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30

A request can be answered from four different caches before it reaches your code, and each one is owned by someone else. Knowing which layer served a response is the difference between a one-line fix and an afternoon of confusion.

The layers

LayerScopeCan you purge it?
Browser or HTTP clientOne user, one deviceNo
CDN or reverse proxyEveryone, per edge locationYes, via a purge API
Application cache (Redis, Memcached)EveryoneYes, you own it
Database buffer poolOne database instanceNot meaningfully, and you do not want to

What each layer can know about freshness

The top two layers only know what your Cache-Control header said at the moment they stored the response. A browser holding max-age=3600 serves that copy for an hour even if the row changed a second later, and no deploy can reach it. s-maxage overrides max-age for shared caches only, which is why the usual shape is a short max-age for browsers plus a long s-maxage for the CDN you can purge.

The application cache is different: your write path knows the row changed, so it can delete the key. The buffer pool is different again — it caches database pages inside the database's own transaction machinery, so it is never stale. Sizing it is a memory decision, not a correctness one.

What trips people up

no-cache does not mean do not store; it means revalidate before reuse. no-store is the one that means do not keep a copy. And the CDN's cache key is not the URL alone — it is the URL plus everything Vary lists, plus whichever query parameters and cookies the configuration includes.

Example

Example · javascript
// One handler, three caches: the browser and the CDN read the headers,
// the application cache is addressed in code and is invisible to HTTP.
import express from 'express';
import { createHash } from 'node:crypto';
import { createClient } from 'redis'; // node-redis v4+

const app = express();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

app.get('/products/:id', async (req, res) => {
  const key = `product:${req.params.id}:v3`; // bump v3 when the payload shape changes
  let body = await redis.get(key);

  if (body === null) {
    const row = await db.product(req.params.id); // your own data access
    if (!row) return res.sendStatus(404);
    body = JSON.stringify(row);
    // SET ... EX 300 is one atomic command; see sd-cache-strategies for the longhand.
    await redis.set(key, body, { EX: 300 });
  }

  res.set({
    // max-age governs the browser (which you cannot purge), so keep it short.
    // s-maxage overrides it for the CDN (which you can purge), so keep it long.
    'Cache-Control': 'public, max-age=60, s-maxage=600, stale-while-revalidate=30',
    // The shared cache stores one entry per distinct value of each listed header.
    Vary: 'Accept-Encoding',
    // Lets the browser revalidate cheaply with If-None-Match once max-age passes.
    ETag: `"${createHash('sha1').update(body).digest('base64url')}"`,
  });
  res.type('json').send(body);
});

When to use it

  • Deciding why a price change is visible to some users and not others after a deploy.
  • Setting headers for a page that is public to anonymous users and personalised once logged in.
  • Choosing between a long CDN TTL plus purges and a short TTL with no purge machinery.
  • Explaining to a reviewer why the database buffer pool is not part of your invalidation plan.

More examples

Long at the edge, short in the browser, purge on write

This is the only safe way to use a multi-hour cache lifetime on content that changes: the layer with the long TTL is the one you can reach. Reach for it whenever a product page, article or profile must go stale within seconds of an edit but still be served from the edge the rest of the time.

Example · javascript
// Serve a copy the CDN will hold for a day but the browser only for a minute.
res.set('Cache-Control', 'public, max-age=60, s-maxage=86400');

// After the write commits, purge the edge. The browser copies simply expire.
async function purgeEdge(urls) {
  const r = await fetch(
    `https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.CF_API_TOKEN}`,
        'Content-Type': 'application/json',
      },
      // Absolute URLs. Single-file purge allows 100 per call on Free, Pro and
      // Business, 500 on Enterprise, so batch longer lists.
      body: JSON.stringify({ files: urls }),
    },
  );
  if (!r.ok) throw new Error(`purge failed: ${r.status}`);
}

Fingerprinted assets, cached forever

Shows the standard split: immutable fingerprinted assets, always-revalidated HTML entry point. Use it on any bundled front end; getting it backwards is the cause of blank pages after a deploy.

Example · javascript
// Build output is content-addressed: app.9f3c21ab.js never changes meaning.
app.use(
  '/assets',
  express.static('dist/assets', {
    // immutable tells the browser not even to revalidate; the filename changes
    // when the bytes change, so there is nothing to revalidate against.
    setHeaders: (res) => res.set('Cache-Control', 'public, max-age=31536000, immutable'),
  }),
);

// The HTML that references them must NOT be cached this way, or users keep
// loading an old index.html that points at deleted asset names.
app.get('/', (req, res) => {
  res.set('Cache-Control', 'no-cache'); // store it, but revalidate every time
  res.sendFile('dist/index.html', { root: process.cwd() });
});

The layer you tune rather than invalidate

A low ratio of disk reads to logical reads means the buffer pool holds the working set, and an application-level cache in front of it is buying you less than you think. Run this before adding Redis to a read-heavy endpoint; sometimes the answer is more RAM on the database.

Example · sql
-- The InnoDB buffer pool is a cache you size, not a cache you invalidate.
-- MySQL 8.0/8.4: check how much of the working set actually fits.
SELECT POOL_SIZE, FREE_BUFFERS, DATABASE_PAGES, MODIFIED_DATABASE_PAGES
  FROM information_schema.INNODB_BUFFER_POOL_STATS;

-- Read pages served from memory vs read from disk, since server start.
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
--  Innodb_buffer_pool_read_requests : logical reads (hits + misses)
--  Innodb_buffer_pool_reads         : the ones that had to touch disk

Discussion

  • Be the first to comment on this lesson.