Avoiding Early Hints Cache Poisoning
Diagnosis: after enabling edge-generated Early Hints, one cohort of users — mobile, or a non-default locale, or clients that advertise a different image format — receives a 103 whose Link headers point at another variant’s assets, so the browser downloads a stylesheet and a hero image the document never references, logs unused-preload warnings, and then fetches the real ones at parse time.
Root cause: the hint store and the response cache are keyed differently
The 103 is not cacheable. RFC 9111 lets an HTTP cache store only final responses, so an edge that serves Early Hints on a cache hit cannot replay a stored interim response — it has to synthesise one. To do that it keeps a second store, mapping some key derived from the request to a hint set it either learned from a previous final response’s Link header or took from static configuration. That store is not the response cache. It has its own key, its own eviction policy and, critically, its own idea of which requests are equivalent.
Now consider what the edge knows at the moment it must emit the hint. The whole value of the 103 interim response is that it leaves the edge before the origin is contacted, during the think-time the hints are supposed to overlap. At that instant the edge has the request line and the request headers, and nothing else. It does not have the origin’s Vary header, because Vary is a property of a response that does not exist yet. So the hint key can only ever be built from request-side data that the platform was configured, in advance, to consider — in practice the host and the normalised path, sometimes plus one or two allow-listed headers.
The response cache key, meanwhile, is built after the fact from Vary. If the origin answers Vary: Cookie, Accept and splits its critical CSS by device class, the response cache correctly holds two entries for /p/42 while the hint store holds exactly one. Whichever variant wrote the store last wins, and every request for that path — desktop, mobile, AVIF-capable, WebP-only — receives its hint set. That is the poisoning: not a corrupted response, but a correct hint set delivered to requests it was never valid for.
Two variations on the same fault are worth naming separately, because they need different fixes. Deploy skew is poisoning across time rather than across variants: the store holds a hint set written before a release, the release renames every fingerprinted asset, and until the store is overwritten or purged the edge hints URLs that now return 404. Personalisation leakage is poisoning across users: a route that emits hints after authentication contributes a hint set containing an account-scoped URL, the store keys it on the path alone, and the next anonymous visitor is told to preload it. The first costs bandwidth and one deploy’s worth of LCP; the second is a correctness and privacy defect and is the reason hint sets should never be drawn from anything but the anonymous static asset space.
Minimal reproduction
The whole fault is visible with two requests that differ only in one request header. Send both against the edge, extract the URLs from the 103, and check them against the body the origin actually returned:
# Ask the SAME path twice, differing only in the dimension the origin varies on.
# --http2 is load-bearing: an edge will not emit a 103 to an HTTP/1.1 client, so
# over 1.1 this script silently "passes" by finding no hints to check at all.
for DC in desktop mobile; do
echo "== dc=$DC"
# Capture headers and body separately: the 103 link headers arrive on the
# interim response, the referenced URLs live in the final body.
curl -sS --http2 -D /tmp/h.$DC -o /tmp/b.$DC \
-H "Cookie: dc=$DC" -H 'Accept: text/html' https://www.example.com/p/42
# Pull every URI-reference out of the interim response's link headers. The
# 103 block is everything before the first blank line following "HTTP/2 103".
grep -i '^link:' /tmp/h.$DC | grep -o '<[^>]*>' | tr -d '<>' | while read -r U; do
# A hint is only legitimate if THIS variant's document references it.
# A miss here is the poisoning signature: valid header, wrong variant.
grep -qF "$U" /tmp/b.$DC && echo " ok $U" || echo " POISON $U"
done
done
On a poisoned edge the loop prints ok for the variant that last wrote the store and POISON for every other one:
== dc=desktop
ok /css/app.desktop.a91f.css
ok /img/hero-desktop.avif
== dc=mobile
POISON /css/app.desktop.a91f.css
POISON /img/hero-desktop.avif
What that costs on the wire is worth looking at directly, because the median across all traffic can stay flat while the poisoned cohort regresses hard. The hinted fetches start during think-time and are therefore first in the connection’s queue: they consume the early bandwidth window, and under HTTP/2 they compete with the document itself for it, which is the same stream prioritization pressure that over-preloading causes in markup, only earlier and with bytes that are guaranteed to be useless.
Note that the mobile document in the poisoned panel still gets its stylesheet — it just gets it at parse time, exactly as it would with no Early Hints at all, plus 352 KB of contention. Poisoning is not a partial win; on a constrained link it is strictly worse than the un-hinted baseline.
Which variance dimensions actually poison a hint set
Vary is a broader condition than the one that matters here. A hint set is a list of URLs, so a dimension only threatens it when it changes which URLs the document references. Accept-Encoding produces a Brotli and a gzip copy of the same stylesheet at the same URL, and both variants want an identical hint set. Accept with a format-negotiating image endpoint, a device-class cookie that swaps bundles, and a locale prefix that splits chunks all change the URL list, and each one is a collision waiting to happen.
The second question is whether the edge can even see the dimension. A device-class cookie is in the request, so it can be added to the hint key. An experiment assignment made by the origin’s bucketing logic is not: by the time the variant is known, the 103 has already been sent. Routes in that state have exactly two safe options — hint only the URLs that are invariant across every branch, or emit no hints at all.
Deterministic fix protocol
Work the list in order: steps 1 to 3 stop the cross-variant collision, 4 to 6 remove the classes of collision you cannot key your way out of, and 7 to 8 keep it fixed.
- [ ] 1. List the
Varyheader of every route that emits hints. Then mark each dimension “URL-changing” or not. Only the URL-changing ones can poison a hint set;Accept-Encodingalmost never can,Acceptand a device-class cookie almost always can. - [ ] 2. Run the variant diff against the edge, not the origin. The origin passes trivially — it composes hints and body together. The whole defect lives in the edge’s replay path, so every check must go through the edge hostname with the real request headers.
- [ ] 3. Add every URL-changing dimension to the edge’s hint key. Most platforms expose this as a per-route list of headers or cookies to include in the Early Hints lookup. If yours does not, the hint set has to become invariant instead — go to step 4.
- [ ] 4. Collapse the variance you cannot key on. Serve one stylesheet for all device classes and branch inside it with media queries; replace a format-negotiating image URL with a single
preloadcarryingimagesrcsetandtype, so the browser picks the candidate and the hint stays valid for every client. - [ ] 5. Restrict hint candidates to the anonymous asset space. Reject any URL bearing a user id, a session token or a signed query string before it reaches the store. This is a hard filter, not a review step: a personalised URL in a shared hint set is a leak, not a slow page.
- [ ] 6. Version the hint set with the deploy. Generate it from build output, stamp it with the release id, and purge or re-prime the edge hint store during the release. Otherwise the first cold requests after every deploy are hinted at fingerprints that no longer exist — the deploy-skew window described in enabling 103 Early Hints on CDN and origin.
- [ ] 7. Gate releases on the diff. Run the reproduction loop in CI against a canary, once per variant, and fail the build when a hinted URL is absent from its document or answers anything other than
200. A 404 hint is cheap to detect and expensive to ship. - [ ] 8. Alarm on the unused-hint rate per cohort, not in aggregate. A key regression that affects 30% of traffic moves the median by almost nothing and moves that cohort’s LCP by a second.
That last step is the one people skip, and it is the only one that catches the next regression. Chromium reports resources started from a 103 with an early-hints initiator, so the field signal is a short observer:
// Cohort-segmented unused-hint monitor. Scheduling rationale: a hinted resource is
// fetched during think-time and parked in the preload cache; if the parser never
// claims it, the browser holds it ~3 s and discards it. So we sample AFTER the
// document has been fully parsed — earlier, a hint that is about to be used still
// looks unused, and the beacon would report every healthy load as poisoned.
const hinted = new Set();
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
// Chromium-only attribution; other engines report the resource's real type,
// so treat a missing signal as "no data" rather than as "no poisoning".
if (e.initiatorType === 'early-hints') hinted.add(e.name);
}
}).observe({ type: 'resource', buffered: true });
addEventListener('load', () => {
// A hinted URL that no element or stylesheet resolved to is a poisoned hint.
const used = new Set([...document.querySelectorAll('link[href],img[src],script[src]')]
.map((el) => new URL(el.href || el.src, location.href).href));
const wasted = [...hinted].filter((u) => !used.has(u));
if (wasted.length) {
navigator.sendBeacon('/rum/early-hints', JSON.stringify({
route: location.pathname,
cohort: document.documentElement.dataset.deviceClass, // the Vary dimension
wasted, // the poisoned URLs
}));
}
});
The same warning is visible per-load in the console, and reading it correctly is covered in debugging “preloaded but not used” console warnings; the difference here is that the warning names an asset nobody on your team put in that page’s markup, which is the tell that it came from a hint store rather than from a stale <link>.
Before and after
One catalogue route, edge-generated hints learned from final responses, 640 ms p50 origin think-time, measured over a week of field data before and after the device-class cookie and Accept were added to the edge’s Early Hints key and the hint set was moved into the build output.
| Signal | Before (unkeyed hint store) | After (keyed + versioned) | Change |
|---|---|---|---|
| Hinted URLs absent from the document, mobile | 2 of 2 | 0 of 2 | fixed |
| Hinted URLs absent from the document, desktop | 0 of 2 | 0 of 2 | — |
| Wasted early bytes per mobile navigation | 352 KB | 0 KB | −352 KB |
| Unused-preload warnings per mobile load | 2 | 0 | −2 |
| Render-blocking CSS start, mobile | 780 ms | 30 ms | −750 ms |
| LCP p75, mobile cohort | 3.40 s | 2.05 s | −40% |
| LCP p75, desktop cohort | 2.10 s | 2.08 s | −1% |
| LCP p75, all traffic | 2.62 s | 2.07 s | −21% |
| 404s on hinted URLs, first hour after deploy | 1,840 | 0 | eliminated |
| Origin egress attributable to hints | 41 GB/day | 12 GB/day | −71% |
The desktop row is the point of the table. Desktop was the cohort that wrote the store, so every desktop measurement said Early Hints was working perfectly, and it was — the aggregate improvement over the un-hinted baseline was real and the feature looked like a clean win. The regression was entirely inside a cohort nobody had segmented, and it only surfaced when someone asked why mobile LCP had got worse on the week Early Hints shipped.
FAQ
Q: Can a 103 poison a shared HTTP cache?
Not the response cache. HTTP caches store only final responses, so an interim response is never stored and never replayed as a cached response. What gets poisoned is the edge’s separate Early Hints store — keyed, populated and evicted independently of the response cache — and the client’s preload cache, which fills with typed entries the document never claims and which are discarded a few seconds later. There is one genuinely dangerous variant: hinting a URL whose response is user-specific but marked publicly cacheable. The hinted fetch is issued before the document exists and, depending on the crossorigin mode, may be anonymous, so it can populate a shared cache with content that belongs to one account. Restricting hint candidates to the anonymous static asset space, as in step 5, removes that case structurally rather than by review.
Q: We only vary on Accept-Encoding. Is our hint set safe?
Yes, and the reason generalises. Accept-Encoding changes the bytes of a response, not the set of URLs the document references, so every encoding variant of a route wants exactly the same hint set. The condition that matters is narrower than Vary: a hint set may be shared by precisely those requests whose documents would reference the same subresource URLs. Vary: Accept on a format-negotiating image path fails that test, a device-class cookie that swaps bundles fails it, a locale that splits chunks fails it — and Vary: Accept-Encoding passes it. Check the condition, not the header, or you will end up keying the store on dimensions that only multiply your hint-store cardinality and lower its hit rate for nothing.
Q: Should we abandon learned hints and configure a static hint set per route instead?
Static configuration is a real improvement: it removes the learning race, so the store is never populated from whichever variant happened to be first, and it removes the dependency on a recent final response. It does not remove the keying problem. A statically configured set is still replayed to every variant of that route unless the platform lets you key it, and it now drifts on every deploy that renames a fingerprint unless the configuration itself is generated from the build. So the durable fix is identical in both models — generate the hint set from build output, key it on every dimension that changes the URL set, purge on deploy, and gate the release on a diff between the hinted URLs and the document that will actually be served.
Related
- Enabling 103 Early Hints on CDN & Origin — choosing between edge-generated and passthrough delivery, which decides whether a hint store exists at all
- Up: 103 Early Hints Implementation