HTTP 103 Early Hints, Explained the Way I Wish Someone Had Explained It to Me
π 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 stylesh
π 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.
How to test it
Remember page JS can't see a 103 at all, so none of this is a console.log away. Reach for these instead, roughly in order of increasing confidence:
-
curl -v --http2 <url>and look for the103line ahead of the final200, against staging or production, not just your local server. This confirms the server (and everything in front of it) actually emits and forwards the response. - Chrome DevTools Protocol or Firefox's WebDriver BiDi β check
Network.responseReceivedEarlyHintsfired with the headers you expect. More reliable than eyeballing the Network panel. - Per-resource signals β
initiatorType,fromDiskCache,transferSizeβ to confirm the browser actually acted on the hint, not just received it. Raw timestamps will mislead you, for the reasons above. - A lab A/B on FCP/LCP with enough interleaved runs to be statistically confident, not a single manual comparison β ideally under a realistic network profile for your actual users, not your office Wi-Fi.
- Field data β RUM or CrUX β before calling it a win. Everything before this step tells you the mechanism works; only real traffic tells you it mattered.
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.
Further reading
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.