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.
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
Linkheaders 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. - 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
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 --http2check 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 unused-preload warnings. - [ ] 5. Mirror the hints on the final 200. Emit the identical
Linkheaders on the200so 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
crossoriginandasattributes. For each hinted resource confirm theasvalue and, for CORS resources like fonts, thatcrossoriginmatches 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.
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
- 103 Early Hints Implementation — the interim-response semantics and hint-set design this rollout depends on
- HTTP/2 Server Push vs 103 Early Hints — migrating a legacy push configuration to the hint set you deploy here