Skip to the solution
MediumSystem Design11 min read

Edge caching on Cloudflare — how do you get 95%+ hit rate without stale data?

1.2k views

The problem

Question presented to candidate: "Our news site sits behind Cloudflare, but the dashboard shows a cache hit rate around 30% and the origin is struggling. Editors also complain that when they fix a headline, readers keep seeing the old one. How would you get the hit rate above 95% without serving stale articles?"

What a strong answer should cover:

  • Why the hit rate is low out of the box: Cloudflare caches by file extension and does not cache HTML or JSON by default; Set-Cookie, private or no-store responses are not cached; tracking query strings split one page into thousands of cache keys
  • The core move: cache HTML with a long edge TTL and make freshness event-driven by purging by tag (Cache-Tag header) when content changes, instead of relying on short TTLs
  • Cache key normalisation: ignore or strip utm_*, fbclid, gclid; sort query strings; never vary on cookies for public pages
  • Separate edge TTL from browser TTL (CDN-Cache-Control / Cloudflare-CDN-Cache-Control, or Cache Rules), so purges reach users
  • Origin protection: Tiered Cache (an upper-tier PoP between edges and origin), stale-while-revalidate, and Cache Reserve for the long tail
  • Measuring it: cf-cache-status, hit rate by path, and origin offload rather than a single number

Clarifying questions expected:

  • Which responses are personalised (logged-in pages, paywall), and which are the same for everyone?
  • How quickly must an edit be visible, and does the CMS emit an event on publish?
  • Which Cloudflare plan, since some cache-key controls are Enterprise-only?

Code / implementation expected: Optional — the response headers, a Cache Rule outline and a purge call on publish; the example simulates a day of traffic under each strategy.

  • cdn
  • edge
  • caching

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read · 10 sections

Read the code

A day at one edge: TTL vs purge by tag vs cache-key normalisation vs stale-while-revalidate
// One Cloudflare-style edge cache in front of a news site for one simulated day.
// The edge is a Map (no eviction); the origin is a version counter per page.
// Purge is modelled as instant; Cloudflare reports under 150 ms P50 for Instant Purge.

let seed = 11;
function rand() { // Park-Miller generator, identical in every language
  seed = (seed * 48271) % 2147483647;
  return seed / 2147483647;
}
const ARTICLES = 2000, N = 300000, DAY = 86400;
const cdf = [];
let total = 0;
for (let k = 1; k <= ARTICLES; k++) { total += 1 / k; cdf.push(total); }
function pick() { // Zipf popularity: article k has weight 1/k
  const u = rand() * total;
  let lo = 0, hi = ARTICLES - 1;
  while (lo < hi) { const mid = (lo + hi) >> 1; if (cdf[mid] < u) lo = mid + 1; else hi = mid; }
  return lo + 1;
}

// Build the day once so every strategy sees the same traffic and the same edits.
const events = [];
for (let i = 0; i < N; i++) {
  const t = Math.floor((i * DAY) / N);
  const r = rand();
  const page = r < 0.1 ? "/" : "/a/" + pick();
  const q = rand();
  const query = q < 0.2 ? "?utm_source=s" + Math.floor(rand() * 30) : q < 0.3 ? "?fbclid=" + i : "";
  events.push({ t, url: page + query, page });
}
const edits = []; // an editor updates an article every 432 s (199 edits in the day)
for (let t = 432; t < DAY; t += 432) edits.push({ t, page: "/a/" + pick() });

function run(name, cfg) {
  const version = new Map(), edge = new Map(), byTag = new Map(); // byTag: page -> its cache keys
  const ver = (p) => version.get(p) || 0;
  let hits = 0, origin = 0, stale = 0, maxStale = 0, e = 0;
  const changedAt = new Map();
  for (const ev of events) {
    while (e < edits.length && edits[e].t <= ev.t) { // apply edits that happened before this request
      const ed = edits[e++];
      for (const p of [ed.page, "/"]) { version.set(p, ver(p) + 1); changedAt.set(p, ed.t); }
      if (cfg.purgeTags) for (const p of [ed.page, "/"]) { // purge by tag: every key carrying the tag
        for (const k of byTag.get(p) || []) edge.delete(k);
        byTag.delete(p);
      }
    }
    if (!cfg.cache) { origin++; continue; }
    const key = cfg.stripTracking ? ev.page : ev.url; // the cache key
    const c = edge.get(key);
    const fresh = c && ev.t < c.storedAt + cfg.ttl;
    const swr = c && !fresh && ev.t < c.storedAt + cfg.ttl + (cfg.swr || 0);
    if (fresh || swr) {
      hits++;
      if (c.v !== ver(ev.page)) { stale++; maxStale = Math.max(maxStale, ev.t - changedAt.get(ev.page)); }
      if (swr) { origin++; edge.set(key, { v: ver(ev.page), storedAt: ev.t }); } // background refresh
      continue;
    }
    origin++;
    edge.set(key, { v: ver(ev.page), storedAt: ev.t });
    if (!byTag.has(ev.page)) byTag.set(ev.page, new Set());
    byTag.get(ev.page).add(key);
  }
  const pct = (Math.round((hits * 1000) / N) / 10).toFixed(1);
  console.log(`  ${name.padEnd(36)} ${pct.padStart(5)}%  ${String(origin).padStart(7)}  ${String(stale).padStart(6)}  ${String(maxStale).padStart(9)}`);
}

console.log(`One day: ${N} requests, ${ARTICLES} articles + home page, ${edits.length} edits`);
console.log("  strategy                              hit    origin   stale  max stale s");
run("default (HTML not cached)", { cache: false });
run("cache everything, TTL 60 s", { cache: true, ttl: 60 });
run("TTL 1 day, no purge", { cache: true, ttl: DAY });
run("TTL 1 day + purge by tag", { cache: true, ttl: DAY, purgeTags: true });
run("  + strip utm and fbclid from key", { cache: true, ttl: DAY, purgeTags: true, stripTracking: true });
run("TTL 300 s + swr 600 s, clean key", { cache: true, ttl: 300, swr: 600, stripTracking: true });

const distinct = new Set(events.map((x) => x.url)).size, clean = new Set(events.map((x) => x.page)).size;
console.log(`\nDistinct cache keys: ${distinct} with raw URLs, ${clean} after stripping tracking parameters`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 5 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

What is the difference between REST and GraphQL?

Continue