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.

One hint store, two response variants: where the cache keys diverge Two rows. The top row is a desktop request for slash p slash 42 carrying a device-class cookie; the hint store is keyed on host and path only, hits, and returns a 103 preloading the desktop stylesheet, which the desktop HTML does reference. The bottom row is a mobile request for the same path; it produces the identical hint key, receives the same desktop hint set, but the mobile HTML references a different stylesheet, so the hinted fetch is wasted and the real one is fetched again. A footer explains that the 103 must leave the edge before the origin is contacted, so the hint key cannot include a response-side Vary dimension. One hint store, two response variants: where the cache keys diverge Edge-generated Early Hints on /p/42, origin answering Vary: Cookie, Accept. Incoming request Edge hint store lookup What the browser receives GET /p/42 Cookie: dc=desktop Accept: image/avif Viewport: 1440 px key = host + /p/42 hit — set written at 11:02 from a desktop 200 response 103 preloads app.desktop.a91f.css 200 desktop HTML, same URL hint used GET /p/42 Cookie: dc=mobile Accept: image/webp Viewport: 390 px key = host + /p/42 same key — the cookie is not part of the hint lookup at all 103 preloads app.desktop.a91f.css 200 mobile HTML wants .mobile wasted fetch, then a refetch Why the two keys can never match on their own The 103 leaves the edge before the origin is contacted, so the hint lookup can only use request-side data. Vary is response-side: it does not exist yet. Every Vary dimension absent from the hint key is a collision.

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.

The same mobile navigation with a poisoned hint set and with a keyed one Top panel: the document waits 640 milliseconds for the origin while two hinted desktop assets download in parallel and are never used, and the real mobile stylesheet is not discovered until the parser reaches it at 780 milliseconds, giving a largest contentful paint of 3.40 seconds. Bottom panel: with the device-class cookie added to the hint key, the same 640 millisecond think-time is spent fetching the two assets the mobile document actually references, no bytes are wasted and largest contentful paint falls to 2.05 seconds. One mobile navigation, 640 ms origin think-time, before and after the hint key was fixed Poisoned — the mobile request is served the desktop hint set document /p/42 TTFB 640 ms of origin think-time hinted: app.desktop.css 42 KB — this document never links it hinted: hero-desktop.avif 310 KB — a 1600 px hero on a 390 px viewport real: app.mobile.css found by the parser at 780 ms, not hinted 352 KB of wasted early bandwidth, the render-blocking stylesheet starts 750 ms late, LCP 3.40 s. 0 900 1800 2700 3600 ms Keyed — the device-class cookie is part of the hint lookup document /p/42 same 640 ms TTFB — the origin did not change hinted: app.mobile.css 36 KB — the URL this document references hinted: hero-mobile.avif 88 KB, in the cache before the HTML arrives 0 KB wasted, no unused-preload warnings, LCP 2.05 s on the identical origin latency. 0 900 1800 2700 3600 ms connect + origin wait document download poisoned hint fetch hint the document uses

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.

Deciding whether a variance dimension can poison a learned hint set A tree starting from a dimension the origin varies on. The first question asks whether the dimension changes which URLs the HTML references; if not, one hint set is safe for every variant. If it does, a second question asks whether the dimension is readable from the request alone; if it is, add it to the edge hint key, and if it is not — an origin-side experiment, a geo decision or an entitlement check — do not emit learned hints for that route. Which variance dimensions can poison a learned hint set Origin varies the response on request dimension X Does X change which URLs the HTML links? the URL list, not the bytes behind it no yes One hint set serves every variant Accept-Encoding, Vary on a session cookie that only changes rendered copy Is X readable from the request alone? before the origin has been contacted yes no Add X to the hint key device cookie, Accept, path prefix Emit no learned hints here origin-side tests, geo, entitlements The one-line rule A hint set may be shared by exactly those requests whose documents would reference the same URLs. Anything coarser than that is poisoning.

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 Vary header 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-Encoding almost never can, Accept and 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 preload carrying imagesrcset and type, 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.