Enabling 103 Early Hints on CDN & Origin

Your application code emits a 103 with a perfect hint set, your logs confirm it, and the browser still never fetches a thing early — because somewhere between your origin and the user, a proxy or an HTTP/1.1 hop quietly swallowed the interim response.

Root cause: an interim response only survives an all-forwarding, all-HTTP/2 path

A 103 Early Hints response is fragile in a way final responses are not. It is an interim response — the server sends it, then later sends the real 200 on the same request — and for it to reach the browser, every intermediary on the path must forward it unbuffered. That assumption breaks in two common places.

The first is a buffering proxy. Many reverse proxies and CDN configurations, by default, wait for the complete origin response before sending anything downstream. That behaviour is fine for 200s but fatal for a 103: the proxy holds the interim response, then the final response supersedes it, and the browser sees only the 200. The hint that was supposed to fire during think-time never leaves the edge. Enabling Early Hints on such a platform is often less about generating the 103 and more about telling the proxy to forward interim responses.

The second is a protocol downgrade. Interim responses are not used over HTTP/1.1 in practice — the framing makes an unsolicited early header block impractical, and browsers do not act on 103 over 1.1. So even if your origin speaks HTTP/2 to the CDN, a single leg of the path that negotiates HTTP/1.1 — an internal load balancer, a legacy origin shield — collapses the feature. The entire chain from browser to the component that emits the 103 must be HTTP/2 or HTTP/3.

Both failures live on the return path, and both are invisible from the origin’s own logs — the application records a successfully written 103 in either case:

Hop chain from browser to origin showing the two legs where a 103 interim response is destroyed Four hops in a row: browser, CDN edge, internal load balancer and origin app. The 103 travels right to left. On the load-balancer-to-origin leg the protocol is HTTP/1.1 and the interim response is never framed. On the edge-to-browser leg a buffering CDN holds the response until the final 200 arrives and discards the 103. The middle leg, edge to load balancer, is HTTP/2 and would have forwarded it. HTTP/2 HTTP/2 HTTP/1.1 Browser sees only the 200 CDN edge buffers by default Internal LB negotiates 1.1 Origin app emits the 103 X X Drop 1 — an HTTP/1.1 hop One 1.1 leg and the interim response is never framed. Fix: h2 or h3 on every leg. Drop 2 — a buffering edge The edge waits for the 200, discarding the stale 103. Fix: forward interim frames.

This is why the delivery model matters. There are two, and choosing the right one is most of the work:

  • Edge-generated. The CDN itself emits the 103, built from a static per-route hint set or from Link headers it learned by observing earlier final responses. This is the strong model: the hint reaches the browser during the origin’s think-time even if the origin is slow, because the edge does not wait for the origin to respond. It also sidesteps the buffering problem entirely, since the edge is the one generating the interim response. The cost is that a learned or cached hint set is keyed by the edge, not by the application, so it can be served against a route it was never learned from — see avoiding Early Hints cache poisoning for the key-scoping rules that keep that from happening.
  • Origin-passthrough. The application emits the 103 and every hop forwards it to the browser. Correct when the hint set is genuinely dynamic and only the application knows it, but it demands an all-forwarding, all-HTTP/2 path and yields less, because the origin must at least start handling the request before it can flush the hints.

How the two models place the 103 on the timeline

Two request timelines comparing edge-generated Early Hints against origin-passthrough Early Hints across a 500 ms origin think-time Top lane, edge-generated: the CDN emits the 103 the moment the request arrives, so the browser has the full 500 ms of origin think-time to fetch hinted assets. Bottom lane, origin-passthrough: the origin spends roughly 180 ms handling the request before it flushes the 103, leaving a 320 ms early-fetch window that also depends on every intermediary forwarding the interim response. request received (t=0) origin 200 flushed (t=500 ms) Edge-generated 103 103 emitted on arrival — the edge never waits for the origin ~500 ms of think-time available for early fetches the edge model puts the hint on the wire ~180 ms earlier Origin-passthrough 103 origin handling ~180 ms 103 ~320 ms window — if every hop forwards it

Minimal reproduction: what a working exchange looks like

You are testing for one thing: a 103 line arriving before the 200 on the same request. With curl speaking HTTP/2, a correctly delivered Early Hints response looks like this:

# --http2 forces h2 so interim responses are framed the way browsers see them;
# -v prints the interim 103 that a plain request would hide. The 103 must appear
# BEFORE the 200 — that ordering is the whole proof the hint arrived early.
curl -v --http2 https://your-site.example/product/42 2>&1 | grep -E '^< HTTP|^< link'

A healthy path prints, in order:

< HTTP/2 103
< link: </css/app.css>; rel=preload; as=style
< link: </fonts/inter.woff2>; rel=preload; as=font; crossorigin
< HTTP/2 200
< link: </css/app.css>; rel=preload; as=style
< link: </fonts/inter.woff2>; rel=preload; as=font; crossorigin

If you see only the 200, the 103 is being dropped — jump to step 3 of the protocol. If curl shows the 103 but the browser still fetches late, the hint set or its crossorigin attributes are wrong, not the delivery.

Deterministic rollout protocol

Roll out from the edge inward, proving delivery at each hop before adding the next.

  • [ ] 1. Confirm end-to-end HTTP/2 or HTTP/3. Verify the browser-to-edge and edge-to-origin legs both negotiate h2/h3. Any HTTP/1.1 hop will strip the interim response — resolve that first, because nothing downstream matters until it is fixed.
  • [ ] 2. Choose the delivery model. If the platform can generate 103 at the edge from a static or learned hint set, prefer that. Fall back to origin-passthrough only when the hint set must be computed per request by the application.
  • [ ] 3. Enable interim-response forwarding on the CDN. If passing through from the origin, switch the relevant config from response buffering to forwarding interim responses. Re-run the curl -v --http2 check and confirm the 103 now appears.
  • [ ] 4. Define a tight hint set. Include only render-critical resources — the render-blocking stylesheet, the critical font, the LCP image, and at most one or two preconnects. An over-broad set turns into wasted early fetches and preloaded-but-not-used console warnings, which are the fastest signal that the hint set has drifted from what the page actually renders.
  • [ ] 5. Mirror the hints on the final 200. Emit the identical Link headers on the 200 so cache-cold browsers and edge learners still benefit, and so a browser that ignored the interim response is not left worse off.
  • [ ] 6. Validate the crossorigin and as attributes. For each hinted resource confirm the as value and, for CORS resources like fonts, that crossorigin matches the eventual request — a mismatch double-fetches, erasing the head start.
  • [ ] 7. Measure in the field. In DevTools, confirm the hinted resources initiate during think-time (their start time precedes the document’s response) and that LCP improved. In WebPageTest, the waterfall should show the assets beginning before the base HTML completes.

Steps 1 through 3 are strictly ordered, and the order is a decision tree rather than a preference — each answer removes work that would otherwise be wasted:

Decision tree for choosing an Early Hints delivery model, gating on end-to-end HTTP/2, edge generation support, and interim-response forwarding Three sequential gates in a left-hand column. If any hop is not HTTP/2 or HTTP/3, stop and fix that leg. If the edge can generate the 103 itself, take that path for a 500 ms early-fetch window. If not, check that the CDN forwards interim responses and switch it if it does not. Only when all three gates pass is origin-passthrough viable, at a 320 ms window. 1. Every hop on h2 or h3? browser, edge, LB, origin Stop. Fix the HTTP/1.1 leg. No 103 survives a 1.1 hop. No Yes 2. Can the edge emit 103? static or learned hint set Edge-generated — preferred 103 at ~0 ms; ~500 ms window Yes No 3. CDN forwards interim? prove it with curl -v --http2 Switch buffering to forwarding then re-run the curl check No Yes Origin-passthrough is viable — 103 at ~180 ms, ~320 ms early-fetch window every hop must keep forwarding it, and the hint set is recomputed per request

Before/after metrics

Measured on a dynamic route with ~500 ms origin think-time behind a CDN, on a simulated 4G link. “Before” had the origin emitting a 103 that the CDN buffered away; “after” switched the edge to forward (and then generate) interim responses.

Metric Before (103 dropped at edge) After (103 delivered) Change
103 reaches browser No Yes fixed
Critical asset start time ~540 ms (parse-time) ~50 ms (think-time) −490 ms
Largest Contentful Paint 3.0 s 2.2 s −0.8 s
First Contentful Paint 1.8 s 1.3 s −0.5 s
Unused-preload warnings 0 0

The entire win was unlocked by fixing delivery, not by changing the hint set — the origin had been emitting a correct 103 all along, and the browser simply never received it until the edge stopped buffering.

FAQ

Why does my origin’s 103 never reach the browser?

Almost always an intermediary drops it. An interim response only survives if every hop forwards it, and many reverse proxies and older CDN configs buffer the origin response until the final status arrives, discarding the 103. The other common cause is an HTTP/1.1 hop: interim responses are impractical over HTTP/1.1 in the wild, so if any leg of the path negotiates 1.1, the 103 is lost. Confirm the whole chain is HTTP/2 or HTTP/3 and that the CDN is configured to pass interim responses through.

Should the CDN generate the 103 or pass it through from the origin?

Prefer edge generation when the platform supports it. An edge-generated 103, built from a learned hint set or static configuration, reaches the browser during the origin’s think-time even when the origin itself is slow — which is the whole point. Origin-passthrough is correct when the hint set is genuinely dynamic per request and only the application knows it, but it requires every hop to forward interim responses and gains less, because the origin must at least begin processing before it emits the 103.

How do I test for a 103 from the command line?

Use curl with HTTP/2 and verbose output: curl -v --http2 https://your-site/route. In the verbose log you will see a line beginning < HTTP/2 103 with the link headers, printed before the < HTTP/2 200 final response. If you only ever see the 200, the 103 is either not being emitted or is being dropped by an intermediary. Older curl builds silently hide interim responses, so use a recent version.


Related