๐ How I got here
I came across HTTP 103 Early Hints while reading about how larger sites shave milliseconds off their load times. The idea is elegant enough to stop and think about: a server usually knows which stylesheet, script, or hero image a page will need well before it finishes the slow part of the request โ a database call, a template render, an API round-trip. Early Hints lets the server say so early, via an informational 103 response, so the browser can start fetching those resources while the server is still working, instead of waiting for the final 200.
Most of what I read stopped at that explanation. Reasonable diagrams, a sentence about "improves FCP/LCP," and a move on to the next topic. I wanted to actually see it happen โ the real bytes on the wire, a real browser reacting to them, and a real before/after comparison โ rather than take the concept on faith. So I built a small demo app to study it properly, and this post is what came out of that.
The basics, quickly
Before going further, a handful of load-bearing facts worth having straight:
| Topic | Practical takeaway |
|---|---|
| Status class |
103 is informational (1xx), same family as 100 Continue โ it's not the final response, and it's optional to act on |
| Payload |
Link: </app.css>; rel=preload; as=style (also script, image, font, or rel=preconnect) |
| Protocol | HTTP/2 or HTTP/3 in practice โ Chromium ignores Early Hints sent over HTTP/1.1 |
| Scope | Top-level navigations only โ a 103 ahead of a fetch() or XHR call does nothing |
| Visibility | Page JavaScript cannot observe a 103 at all. The only ways to see it are curl -v --http2 or a browser's own debugging protocol |
| Consistency | The final 200 should repeat the same Link hints the 103 sent |
It's spec'd as RFC 8297, and Chrome's own guidance is blunt about the tradeoff: it's not useful if your server can already send a 200 immediately. The whole benefit is overlapping browser work with server think-time โ nothing more.
Sequence diagram of a 103 Early Hints response arriving before the final 200

๐ ๏ธ What I built
I put together a minimal Node app with one job: make the 103 observable, not theoretical.
- A real
http2.createSecureServer()that emits a genuine informational103response โ not a mocked header. - A
/demoroute with ahints=1/hints=0toggle, so the exact same page can be requested with the mechanism on or off. - A server-side timeline log, because I learned early on that the browser's own Resource Timing API has a documented ambiguity for early-hinted resources โ so I wanted a source of truth that couldn't be misread.
- A side-by-side waterfall comparison, so the difference is visible without needing to know how to read DevTools.
-
curl-verifiable output, so anyone can confirm the raw protocol independently of what any particular browser reports.
The demo app's home screen with the WITH/WITHOUT Early Hints controls

The server intentionally has zero runtime npm dependencies โ just Node's built-in fs, path, and http2 โ because I wanted the protocol itself to be the only variable.
The core of it is a handful of lines. Send the hint, do the slow work, then send the real response repeating the same Link headers:
if (hintsEnabled) {
res.writeEarlyHints({
link: [
'</styles/demo.css?s=...>; rel=preload; as=style',
'</scripts/demo.js?s=...>; rel=preload; as=script',
'</images/hero.svg?s=...>; rel=preload; as=image',
],
});
}
await sleep(2000); // simulated DB / SSR think-time
res.writeHead(200, { /* same Link headers, repeated */ });
res.end(html);
Here's the raw protocol, unedited:
curl -v --http2 -k "https://localhost:8103/demo?hints=1"
< HTTP/2 103
< link: </styles/demo.css?s=...>; rel=preload; as=style, </scripts/demo.js?s=...>; rel=preload; as=script, ...
<
< HTTP/2 200
< content-type: text/html; charset=utf-8
...
The 103 arrives immediately; the real 200 follows a couple of seconds later, once the simulated backend work finishes. That gap is the entire point of the feature โ it's dead time the browser can now spend usefully instead of idly.
๐ What I observed
It works exactly as documented, but the obvious way to verify it is misleading. My first instinct was to compare startTime timestamps between when the 103 arrived and when the resource loaded. That number looked unconvincing on its own โ sometimes single-digit milliseconds, sometimes negative. It took some digging to understand why: modern Chrome folds the 103's arrival into nav.responseStart, so it stops being a usable anchor point, and for a resource that's already sitting in cache by the time the page asks for it, the browser's recorded "start" time collapses to roughly when the page parsed the reference, not when the fetch happened.
The signal that actually proves the hint was honored is a different combination: initiatorType, fromDiskCache, and transferSize.
| Signal | WITH hints | WITHOUT hints |
|---|---|---|
initiatorType |
early-hints |
link |
fromDiskCache |
true |
false |
transferSize |
0 bytes |
3757 bytes |
duration |
1ms |
123ms |
transferSize: 0 combined with fromDiskCache: true is the tell: the browser never went to the network for that stylesheet when the page asked for it, because the 103 had already fetched it into cache while the server was still "thinking."
The same story, visually โ the waterfall with hints on shows the critical assets resolving almost instantly, sitting inside the server's think-time instead of after it:
Waterfall comparison, Early Hints OFF

Waterfall comparison, Early Hints ON

Confirming the hint fired is not the same as confirming it mattered. Those are two separate claims, and conflating them is an easy mistake to make. So I ran an interleaved A/B test โ 20 runs per condition, fresh cache each time โ and measured the metrics that describe actual user experience:
| Metric | WITH hints (mean) | WITHOUT hints (mean) | ฮ | Verdict |
|---|---|---|---|---|
| FCP | 2059ms | 2162ms | 103ms | โ Welch's t โ 21.6 โ overwhelming (p โช 0.0001) |
| LCP | 2306ms | 2408ms | 102ms | โ Overwhelming |
| DOMContentLoaded | 2034ms | 2130ms | 96ms | โ Consistent |
All three metrics converge on roughly the same 100ms delta โ which lines up almost exactly with this demo's own simulated critical-asset latency. That internal consistency is what makes the result trustworthy rather than coincidental.
Timeline visualization showing Early Hints overlapping server think-time

The benefit scales with network latency, which is exactly what the theory predicts. I re-ran the same test under throttled network profiles:
| Profile | RTT | FCP delta (WITHOUT โ WITH) |
|---|---|---|
| No throttling | 0ms | 108ms |
| Fast 4G | 40ms | 112ms |
| Slow 4G | 170ms | 224ms |
| 3G | 300ms | 468ms |
On a fast connection, Early Hints saves about 100ms. On a slow mobile connection, that grows to nearly half a second. Early Hints is fundamentally an RTT-savings mechanism โ the worse your users' connections, the more it's worth having.
โ ๏ธ Two honest caveats before anyone takes these numbers further than they should go. First, the think-time and asset latency here are synthetic โ a real app streams HTML, sits behind a CDN, and has a messier mix of cache states than a clean lab run. This is evidence that the mechanism works and scales with RTT, not a promise of an identical win on any particular product. Second, Early Hints only helps on a cold-ish cache โ once a returning visitor already has the assets cached from a prior visit, there's nothing left for the 103 to save. Both are reasons to treat this as lab data, not a substitute for real field measurement (more on that below).
โ The ideal setup
Based on everything above, here's the shape of a deployment where Early Hints is worth using at all:
- Meaningful server think-time. If your time-to-first-byte is already under roughly 100โ200ms, there's nothing for Early Hints to fill โ Chrome's own guidance says as much. It pays off when the server genuinely needs a beat to do its work (a slow API call, a computed template, an origin fetch).
-
A render-blocking resource that isn't already discoverable early. The
103is only useful for something the browser wouldn't otherwise learn about until late โ a stylesheet or font referenced deep in a server-rendered response, not something already sitting at the top of a streamed<head>. -
Real HTTPS, properly provisioned. This is worth calling out explicitly: the mechanism relies on the browser's HTTP cache, and browsers are stricter about caching on connections they don't fully trust. In production, this is a non-issue as long as TLS is terminated with a properly issued certificate โ your CDN, load balancer, or platform's managed TLS all satisfy this by default. It only becomes a footnote if you're experimenting on
localhostwith a self-signed cert, which is a local-tooling detail, not a production concern. -
A request path that passes
1xxresponses through untouched. Any reverse proxy, CDN, or load balancer sitting in front of your app needs to forward informational responses rather than stripping them. This is the single most common way Early Hints silently fails in production โ the server sends a correct103, and something in front of it never lets it reach the browser. Confirm this with your specific infrastructure before assuming it works. -
Stable, non-drifting asset URLs between the
103and the200. If your asset URLs are content-hashed or versioned, make sure the version referenced in the103matches what the final200will reference โ a mismatch either wastes the preload or, worse, risks serving a stale asset.
๐งญ Guidelines if you want to try this in your own app
What to do
- Emit the
103as early as your server can determine the resource list โ typically right after routing, before any slow work begins. - Keep the hinted resource list to what's genuinely critical (main stylesheet, a key font, maybe a hero image). Hinting everything defeats the purpose and can even compete with the final response for bandwidth.
- Match the
103'sLinkheaders to the final200'sLink/<link>tags exactly, including query strings or hashes. - Only apply this to top-level page navigations โ a
103ahead of an API call or client-sidefetch()has no effect, since Early Hints is a navigation-time mechanism.
Where to do it
- HTTP/2 or HTTP/3 origins are the natural fit โ this demo intentionally uses
http2.createSecureServer()because informational responses are a well-supported, first-class part of the protocol there. - Whatever sits in front of your origin (CDN, reverse proxy, load balancer) needs to forward
1xxresponses rather than strip them โ see "the ideal setup" above for why this is the most common way the whole thing silently fails.
๐งช A testing checklist you can reuse
Everything in this post rests on one habit: never let one check stand in for
all three questions that actually matter โ did the server send it, did the
browser act on it, and did it help anyone. Here's that as a checklist, in the
order to actually run it.
Confirm the wire protocol. Run
curl -v --http2 <url>against the real
environment you care about โ staging or production, not just localhost.
Look for< HTTP/2 103ahead of the final< HTTP/2 200. No103? Stop
here โ the bug is the server or something in front of it (most often a
proxy/CDN stripping1xxresponses), and nothing downstream will fix that.Confirm the browser acted, not just received. Use Chrome DevTools
Protocol or Firefox's WebDriver BiDi to watch for
Network.responseReceivedEarlyHints, then check the hinted resource's
initiatorType(early-hints),fromDiskCache(true), and
transferSize(0). Skip raw timestamps โ they're misleading for
early-hinted resources, for reasons explained earlier in this post.Run a real A/B, not a single load. One page load proves nothing โ
network jitter alone swings FCP by tens of milliseconds. Interleave at
least ~15โ20 cold runs per condition (hints on/off) and compare FCP, LCP,
and DOMContentLoaded means, not a single number.Throttle the network and re-check. The benefit should grow as RTT
grows, and shrink toward zero on a fast, low-latency connection โ that's
the signature of a genuine network effect. If the delta doesn't move with
latency, be skeptical of the result.Check the warm-cache case too. Early Hints should do nothing once a
resource is already fresh in cache. If your "win" doesn't disappear on a
warm cache, you're not measuring what you think you're measuring.Validate against real traffic before calling it a win. Every step above
runs in a lab. Ship it behind a flag, split real users withweb-vitalsor
your existing RUM pipeline, and compare distributions over a real sample.
This is the only step that answers "was this worth it," not just "does it
work."
Don't skip ahead to step 6 to settle an argument faster โ if step 1 or 2 is
quietly broken (an untrusted local cert, a proxy eating 1xx), every step
after it is measuring nothing. Full write-up, exact commands, and this repo's
own scripts for steps 2โ5 are in TESTING.md.
Before you ship it
- Make it a feature flag or config toggle, not a hardcoded behavior. If the passthrough assumption above turns out wrong on some edge of your infrastructure (a specific CDN region, a legacy load balancer), you want to switch it off without a deploy.
- Treat this as a targeted optimization, not a default. It earns its complexity only when the conditions in "the ideal setup" above are all true; outside of that, it's easy to add risk (cache-poisoning surface, extra server logic) for no measurable gain.
๐ Where to go from here
Everything above โ the raw benchmark scripts, the full protocol walkthrough, and the architecture notes for the demo itself โ lives in TECH_NOTES.md in the repo, if you want to reproduce any of it or adapt it to your own stack.
I started this because I was tired of taking "it improves FCP" on faith. What I ended up with wasn't just a working demo โ it was a much better sense of exactly where this feature earns its keep and where it's dead weight. That's the part no diagram gives you. If you're weighing whether to build this into a real app, my honest advice is the same thing that got me here: stop reading about it, and go watch it happen.
Top comments (0)