Caching a personalised page: layers, fragments, and negative caching
What it is
A page that differs per user appears uncacheable, and almost none of it actually is. The technique is to separate what varies from what does not, cache them at different layers with different keys, and assemble at request time.
A "personalised" product page:
navigation chrome same for everyone cache 1 day
product details same for everyone cache 1 hour
reviews same for everyone cache 10 min
recommendations same per SEGMENT (~40 of them) cache 5 min
price same per PRICING TIER (~12) cache 1 min
cart badge per user do not cache
"you viewed this" per user do not cache
Six of eight components are shared and two are not. The naive analysis says "the page is
personalised, so Cache-Control: private, no-store," and that makes 100 percent of the page
uncacheable to protect the 4 percent that genuinely is.
The six cache layers a request passes through, each with its own key and eviction:
1. Browser cache per user, per device. You cannot purge it.
2. CDN edge shared, purgeable, geographically distributed
3. Reverse proxy / gateway shared, purgeable, in your infrastructure
4. Application cache in-process, per instance, fastest, smallest
5. Distributed cache Redis/Memcached: shared across instances
6. Database buffer pool the last one, and it is a cache too
Each layer has a different invalidation cost and a different blast radius, and the design question is which layer each piece of data belongs in.
What this is confused with: an all-or-nothing decision about the page. Cacheability is a property of a fragment, not of a URL, and the whole technique is refusing to answer the question at page granularity.
The problem it solves
Treating a page as uncacheable because part of it varies is the single largest source of avoidable origin load.
Product page, measured:
render time: 340 ms
of which per-user content: 12 ms (3.5%)
of which shared content: 328 ms (96.5%)
Cached as a whole page: 0% hit rate, 340 ms every time
Fragment-cached: the 328 ms is served from cache, 12 ms is rendered
Ninety-six percent of the work is being repeated per user to produce bytes that are identical for everyone.
The second problem is negative caching, which is the mirror image and is usually absent entirely:
A product page for a SKU that does not exist:
404 from the origin, uncached
a crawler requesting 200,000 non-existent URLs -> 200,000 origin requests
a broken link on a popular page -> sustained origin load for a 404
A 404 costs the same to generate as a 200 and is cached far less often, because the default configuration on most stacks caches successes and passes errors through.
Mechanics
The layer decision
Which layer? Use it for
─────────────────────────────────────────────────────────────────────
Browser static assets with fingerprinted URLs.
CANNOT be purged, so nothing mutable.
CDN edge anything shared and geographically hot.
Purgeable, and the biggest offload.
Reverse proxy shared content needing logic the CDN cannot
express; a second chance after a CDN miss.
Application (in-process) very hot, small, tolerant of per-instance
divergence: config, feature flags, reference
data. Nanoseconds, and N copies to invalidate.
Distributed (Redis) shared across instances, larger, invalidatable
centrally. Sub-millisecond, and a network hop.
Database buffer pool you do not manage this one, and sizing it is
the highest-leverage database tuning there is.
The in-process versus distributed decision is the interesting one, and it is a consistency trade:
In-process: ~50 ns, no network, no shared failure mode
N copies, invalidation is a broadcast, and divergence is possible
Distributed: ~200 us, one network hop, one copy
central invalidation, and it is a dependency that can fail
A two-layer arrangement is usually right: in-process with a short TTL in front of Redis, so the hot set never crosses the network and the TTL bounds the divergence. That is the same shape as the hot-key mitigation on the Redis page.
Fragment caching and ESI
Edge Side Includes let a cached shell reference fragments the edge assembles:
<!-- The shell: cached for an hour, identical for everyone -->
<html>
<body>
<esi:include src="/fragments/nav" /> <!-- cached 1 day -->
<div class="product">...static details...</div>
<esi:include src="/fragments/price/{sku}" /> <!-- cached 1 min, per tier -->
<esi:include src="/fragments/cart-badge" /> <!-- not cached, per user -->
</body>
</html>
Cache-Control on the shell: public, s-maxage=3600
Cache-Control on nav: public, s-maxage=86400
Cache-Control on price: public, s-maxage=60, Vary: X-Pricing-Tier
Cache-Control on cart-badge: private, no-store
The edge fetches only the uncached fragments, so a returning user's page costs one uncacheable fragment rather than a full render.
whole-page ESI
origin renders per view 1 0.08 (only the cart badge, and only
when it is not already fresh)
p50 TTFB 340 ms 38 ms
ESI's weaknesses are real: support varies by CDN (Fastly and Akamai are strong, Cloudflare has no native ESI), the markup is a separate templating language, and debugging a page assembled from six sources is harder than debugging one render.
Streaming SSR is the modern alternative and it solves a different problem:
// React 18 / Next.js: stream the shell immediately, suspend on the personalised parts.
export default function ProductPage({ sku }) {
return (
<Layout>
<ProductDetails sku={sku} /> {/* static, in the first flush */}
<Suspense fallback={<PriceSkeleton />}>
<Price sku={sku} /> {/* streams when ready */}
</Suspense>
<Suspense fallback={<CartSkeleton />}>
<CartBadge /> {/* per user, streams last */}
</Suspense>
</Layout>
);
}
Streaming improves perceived latency and does not make the response cacheable, because the response is a single stream containing per-user content. ESI makes the response cacheable; streaming makes the uncacheable response feel fast. They are complementary and are frequently confused.
The third approach, and often the simplest:
// Cache the shell; fetch personalisation client-side after first paint.
// The HTML is fully cacheable at the CDN; the personalised bits are a
// separate JSON request that is never cached.
fetch('/api/me/context').then(r => r.json()).then(hydratePersonalisation);
Client-side hydration makes the document fully cacheable at the cost of a second round trip and a visible change after first paint. For a logged-out-heavy site it is usually the right answer; for a logged-in-heavy application it means every page shows a flash of generic content.
Cohort keys: the technique that makes fragments shareable
Per-user content is rarely per-user. Reduce it to a cohort and the fragment becomes shareable:
// At the edge: derive a small cohort key from the user, put it in the cache key.
const tier = jwt.pricing_tier; // 12 distinct values
const segment = jwt.recommendation_segment; // 40 distinct values
request.headers.set('X-Cohort', `${tier}:${segment}`);
Per-user price fragment: 41M distinct cache entries, 0% hit rate
Per-tier price fragment: 12 entries, ~100% hit rate
Going from 41 million keys to 12 is the whole technique, and the design work is finding the smallest cohort that produces the correct output. If prices genuinely differ per user (a negotiated rate), that fragment is per-user; if they differ by tier, it is per tier, and treating the second as the first is the common mistake.
Negative caching
Cache-Control on a 404: public, s-maxage=60, stale-if-error=300
Cache 404s briefly. The tension is that a 404 may become a 200 (a product is published, a user is created), so the TTL must be short enough that creation is visible.
Status Cache? Typical TTL Reasoning
──────────────────────────────────────────────────────────────────
404 yes 30-300 s cheap protection against crawlers and
broken links; short, because it may
become a 200
410 yes long GONE is permanent by definition
301 yes long permanent redirect
302 yes short temporary by definition
403 careful short or none may depend on the requester; check the
cache key includes whatever it depends on
500 NO never a transient failure must not be cached
503 NO never use stale-if-error instead: serve the
last GOOD response, not the error
429 NO never rate limiting is per client
Never cache a 5xx, and the reason is worth stating: caching a 500 turns a transient origin
failure into a sustained outage that outlives the failure. stale-if-error is the correct
mechanism: serve the last known-good response rather than caching the error.
The 403 row is where mistakes happen: if the response depends on the requester and the requester is not in the cache key, caching it serves one user's authorisation decision to everyone. That is the same unkeyed-input problem as cache poisoning.
Negative caching at the application layer matters as much:
// Without negative caching, a miss on a non-existent key hits the
// database every time.
Optional<Product> get(String sku) {
var cached = redis.get("product:" + sku);
if (cached != null) {
return cached.equals(NULL_SENTINEL) ? Optional.empty() // cached MISS
: Optional.of(parse(cached));
}
var fromDb = db.findProduct(sku);
redis.setex("product:" + sku,
fromDb.isPresent() ? 3600 : 60, // SHORT ttl for misses
fromDb.map(this::serialise).orElse(NULL_SENTINEL));
return fromDb;
}
The sentinel value is the mechanism, because a cache cannot distinguish "not cached" from "cached as absent" without one. A shorter TTL for negatives than for positives is the standard shape: it bounds how long a newly-created entity stays invisible.
Bloom filters are the scale version:
// For a large keyspace with many misses, a bloom filter answers
// "definitely not present" without touching the cache or the database.
if (!skuFilter.mightContain(sku)) return Optional.empty(); // ~0 cost
A bloom filter has no false negatives, so "not in the filter" is definitive, and false positives merely fall through to the normal path. For a catalogue with millions of SKUs and heavy invalid-SKU traffic it removes the lookup entirely.
A worked example: a "fully personalised" site at 0 percent hit rate
A subscription media platform. Every page showed the user's name, subscription state and
continue-watching row, so the entire site was served with Cache-Control: private, no-store.
Baseline:
CDN hit rate: 0%
origin renders: 100% of page views
page views: 180M/month
origin instances: 240
p50 TTFB: 410 ms
p95 TTFB (Australia): 1,240 ms
origin cost: ~$162,000/month
404 traffic (crawlers,
dead links): ~11M/month, all reaching origin
Step 1: measure what actually varies. They instrumented the renderer to record which fragments differed across users for the same URL.
fragment distinct values across 100k users render cost
──────────────────────────────────────────────────────────────────────────
page shell + nav 1 18 ms
title/synopsis/artwork 1 64 ms
episode list 1 88 ms
similar titles 1 112 ms
"available in your region" 14 (countries) 9 ms
price/upgrade prompt 6 (subscription tiers) 7 ms
continue-watching row 100,000 (per user) 22 ms
user name / avatar 100,000 (per user) 3 ms
───────
323 ms
Four fragments totalling 282 ms of the 323 ms were identical for every user, and 25 ms was genuinely per-user.
Step 2: cohort keys for the two middle fragments.
// At the edge, from the JWT: two small enums, not a user ID.
request.headers.set('X-Region', claims.region); // 14 values
request.headers.set('X-Tier', claims.tier); // 6 values
region fragment: 14 cache entries
tier fragment: 6 cache entries
Step 3: ESI assembly at the CDN.
<esi:include src="/f/shell" /> <!-- s-maxage=86400 -->
<esi:include src="/f/title/{id}" /> <!-- s-maxage=3600 -->
<esi:include src="/f/episodes/{id}" /> <!-- s-maxage=600 -->
<esi:include src="/f/similar/{id}" /> <!-- s-maxage=600 -->
<esi:include src="/f/availability/{id}" /> <!-- s-maxage=3600, Vary: X-Region -->
<esi:include src="/f/upsell" /> <!-- s-maxage=300, Vary: X-Tier -->
<esi:include src="/f/continue" /> <!-- private, no-store -->
<esi:include src="/f/identity" /> <!-- private, no-store -->
before after
CDN hit rate 0% 91% (by fragment request)
origin renders per view 1.0 0.09
p50 TTFB 410 ms 52 ms
p95 TTFB (Australia) 1,240 ms 180 ms
origin instances 240 38
Step 4: the per-user fragments, reconsidered. The two remaining uncacheable fragments were still 100 percent origin, and they were 25 ms of the original 323.
continue-watching: moved to a client-side fetch after first paint.
The row renders as a skeleton and fills in ~40 ms later.
identity: moved into the JWT itself, so the name and avatar
render client-side with no request at all.
origin renders per view: 0.09 -> 0.01
The identity fragment was free to remove, because the data was already in the token the client held. That is worth checking generally: per-user data the client already possesses does not need a server round trip.
Step 5: negative caching, which was the second-largest win and had been ignored.
404 traffic: ~11M/month
crawlers on removed titles: ~6.2M
broken external links: ~3.1M
malformed URLs, scanners: ~1.7M
all reaching origin: 11M full renders of a 404 page
# On 404 responses:
Cache-Control: public, s-maxage=300, stale-if-error=3600
# On 410 (deliberately removed titles):
Cache-Control: public, s-maxage=604800
404 origin requests: 11M/month -> 84,000/month (-99.2%)
A 99 percent reduction from one header on error responses, which had never been considered because the caching work had focused on successful responses.
And the application-layer negative cache:
// Title lookups by ID from a keyspace of ~40M historical IDs, of which
// ~180k are live. A miss was a full database query.
if (!liveTitleFilter.mightContain(id)) return Optional.empty(); // bloom filter
database queries for non-existent titles: 1.4M/day -> ~600/day
Step 6: what they got wrong first.
initial attempt: cached the 403 returned for geo-blocked titles, with
s-maxage=3600 and no Vary on region.
result: a user in a blocked region requested a title, the 403 was
cached under the plain URL, and users in ALLOWED regions
received the 403 for an hour.
detected: support tickets, ~40 minutes after deploy
fix: Vary: X-Region on the 403, and a purge
Caching an authorisation-dependent response without the authorisation input in the key is the same unkeyed-input error as cache poisoning, arriving through the negative-caching door. The rule that came out of it: a response whose status depends on who is asking must have that input in the cache key, or must not be cached.
Final:
before after
CDN hit rate 0% 94%
origin renders per page view 1.0 0.01
p50 TTFB 410 ms 48 ms
p95 TTFB (Australia) 1,240 ms 170 ms
404 origin requests 11M/mo 84k/mo
DB queries for missing IDs 1.4M/day 600/day
origin instances 240 26
origin cost $162,000/mo $21,000/mo
A site classified as fully uncacheable reached a 94 percent hit rate, and the two largest contributors were fragment separation and negative caching, neither of which required changing what the pages contained.
The transferable finding: measure what varies before deciding what is cacheable. The team had believed the pages were personalised, and 87 percent of the render cost was identical for every user. "Personalised" was a property of two small fragments and had been applied to the whole document.
Production evidence
ESI is an old specification (2001, Akamai and Oracle) and is supported by Akamai, Fastly, Varnish and Squid. Cloudflare has no native ESI, which is why the pattern is often implemented in a Worker instead: fetch the fragments and assemble in code, which is ESI with a different syntax and better debugging.
Varnish's ESI implementation is the reference for the on-premises version, and its documentation is the clearest description of the shell-plus-fragments model.
React 18's streaming SSR and Next.js Partial Prerendering are the current expression of the same separation: a static shell prerendered and cached, with dynamic holes streamed in. Next.js PPR is explicitly described as combining a cached static shell with dynamic content, which is ESI's model inside the framework rather than at the CDN.
Fastly's and Akamai's documentation both recommend caching 404s, and the standard guidance of a short TTL exists because a 404 may become a 200. Caching 5xx is uniformly discouraged in favour of serving stale.
Bloom filters for negative caching appear in Bigtable, Cassandra and RocksDB for exactly this purpose (see LSM trees), which is the same technique at the storage layer.
The cohort-key technique appears in most CDN vendors' personalisation guidance under names like "cache variants" or "audience segmentation," and the consistent advice is to derive the smallest enum that determines the output.
The debate
ESI, streaming SSR, or client-side hydration? They solve different problems and the distinction is worth being precise about. ESI makes the response cacheable. Streaming makes an uncacheable response feel fast. Client-side hydration makes the document fully cacheable at the cost of a round trip and a visual change after first paint. For a logged-out-heavy site, hydration is simplest and usually right. For a logged-in application where every page has personalised content above the fold, ESI or a Worker-based assembly is worth the complexity.
Is ESI worth it in 2026? The markup language is dated and support is uneven, and the pattern is not: assembling a page from independently-cached fragments is correct, and doing it in an edge Worker rather than in ESI markup gets you the same result with a normal programming language and better debugging. I would implement the pattern and not necessarily the specification.
How small should a cohort be? The smallest enum that produces the correct output, and the work is finding it rather than choosing it. Twelve pricing tiers and forty recommendation segments is a cache with 480 entries instead of 41 million; a genuinely per-user negotiated price is per-user and no cohort exists. The failure is treating a cohort-shaped input as per-user out of caution, which costs the entire hit rate.
Should you cache 404s? Yes, briefly, and it is consistently overlooked. A 404 costs as much to generate as a 200, crawler and broken-link traffic is substantial, and 30 to 300 seconds is short enough that a newly-created resource appears promptly. In one case it was 99 percent of the remaining origin load after the success path had been optimised.
Should you ever cache a 4xx that depends on the requester? Only with that dependency in the cache key, and the safer default is not to cache it. A cached 403 without the requester in the key serves one user's authorisation decision to everyone, which is the same class of failure as cache poisoning and arrives through a door people do not guard.
Never cache 5xx? Never. Caching a transient failure extends it past its cause, converting a
30-second origin blip into a five-minute outage. stale-if-error is the correct mechanism
and it is the opposite operation: serve the last known-good response rather than the error.
How many cache layers is too many? Each layer adds an invalidation surface and a place for stale data to hide, and the practical limit is how many you can reason about during an incident. Two application-managed layers (in-process plus distributed) and one shared layer (the CDN) is a shape most teams can operate; adding a reverse-proxy cache on top usually buys less than the debugging cost.
Follow-up Q&A
"How do you cache a personalised page?"
By refusing to answer at page granularity. Measure which fragments actually differ across users: in one case 87 percent of the render cost was byte-identical for everyone and "personalised" described two small fragments. Then cache the shared fragments with their own TTLs, reduce the semi-personal ones to cohort keys, leave the genuinely per-user parts uncached, and assemble at the edge with ESI or a Worker. The naive alternative makes 100 percent of the page uncacheable to protect 4 percent.
"What is a cohort key?"
A small enum derived from the user that determines the output, used in the cache key instead of a user ID. Pricing tier (12 values), region (14), recommendation segment (40). A per-user price fragment is 41 million cache entries at a zero percent hit rate; a per-tier fragment is 12 entries at nearly 100 percent. The design work is finding the smallest cohort that produces correct output, and the common mistake is treating a cohort-shaped input as per-user out of caution.
"ESI or streaming SSR?"
They solve different problems and are frequently confused. ESI makes the response cacheable, because the shell and each fragment are separate cache entries the edge assembles. Streaming makes an uncacheable response feel fast by flushing the shell before the personalised parts are ready, and the response is still one stream containing per-user content, so it is still uncacheable. Use ESI (or a Worker doing the same assembly) for cacheability and streaming for perceived latency, and they compose.
"Which cache layer for what?"
Browser for fingerprinted static assets only, because you cannot purge it. CDN for anything shared, which is the largest offload. In-process for very hot, small, divergence-tolerant data like feature flags at roughly 50 nanoseconds. Distributed for anything shared across instances needing central invalidation, at roughly 200 microseconds. The useful pattern is in-process with a short TTL in front of Redis, so the hot set never crosses the network and the TTL bounds divergence.
"Should you cache 404s?"
Yes, for 30 to 300 seconds. A 404 costs the same to render as a 200, and crawler plus broken-link traffic is substantial: in one case 11 million a month, reduced by 99 percent with one header. Keep the TTL short because a 404 can become a 200 when a resource is created. Cache 410 for much longer, since Gone is permanent by definition.
"Which statuses must never be cached?"
5xx, always. Caching a transient failure extends it beyond its cause and turns a 30-second blip
into an outage as long as the TTL. stale-if-error is the correct tool and does the opposite:
it serves the last known-good response. And 403 or any status that depends on who is asking
must have that input in the cache key, or a cached authorisation decision is served to everyone.
"What is a negative cache sentinel and why do you need one?"
A distinguished value stored to represent "this key does not exist," because a cache cannot otherwise tell "not cached" from "cached as absent," so every miss re-queries the database. Store the sentinel with a shorter TTL than positive entries, so a newly-created entity becomes visible quickly. At scale a bloom filter is better still: it has no false negatives, so "not in the filter" is definitive and costs a memory lookup.
Common misconceptions
"The page is personalised, so it is uncacheable." Cacheability is a property of a fragment. In one measurement 87 percent of the render cost was identical for every user.
"Streaming SSR makes pages cacheable." It improves perceived latency. The response is one stream containing per-user content and is not cacheable. ESI or client-side hydration make it cacheable.
"Per-user data needs a per-user cache key." Most of it is per-cohort: tier, region, segment. Twelve values instead of forty-one million is the difference between a hit rate and none.
"Negative caching is a micro-optimisation." In one case 404 traffic was 11 million requests a month and became 84,000 with one header, which was the largest remaining origin load after the success path had been optimised.
"Caching errors is fine if the TTL is short." Caching 5xx extends a transient failure past its cause. Caching a requester-dependent 4xx without the requester in the key serves one user's authorisation decision to everyone.
Interview delivery note
Say this verbatim: "I would not decide cacheability at page granularity. Measure which fragments actually differ across users: in one case 87 percent of the render cost was identical for everyone and 'personalised' described two small fragments. Then the semi-personal parts usually reduce to a cohort key: a per-user price fragment is 41 million cache entries, a per-tier one is twelve." The reframing and the arithmetic that makes it concrete.
The senior-versus-staff separator is negative caching. A senior engineer fragments the page and gets a good hit rate on successful responses. A staff engineer notices that 404s cost as much to render as 200s, that crawler and broken-link traffic was 11 million requests a month reaching origin uncached, and that one header on the error path removed 99 percent of it. The success path gets the attention and the error path is often the larger remaining load.
The second signal is the 403 caveat. Knowing that caching a requester-dependent status without the requester in the cache key serves one user's authorisation decision to everyone connects negative caching to the unkeyed-input problem, and it is a mistake that reaches production because it arrives through a door nobody guards.
Further reading
- The ESI 1.0 specification and Varnish's ESI documentation, for the shell-and-fragments model.
- Next.js Partial Prerendering and React 18 streaming SSR documentation, for the same separation expressed inside a framework.
- Fastly's and Akamai's guidance on caching error responses, including the recommendation to cache 404 briefly and never cache 5xx.
- The cache-key and Vary page in this chapter, for why a requester-dependent response must have the requester in the key.