DEV Community

Cover image for HTTP 103 Early Hints, Explained the Way I Wish Someone Had Explained It to Me
VignesH KumaR
VignesH KumaR

Posted on

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 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
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 informational 103 response โ€” not a mocked header.
  • A /demo route with a hints=1 / hints=0 toggle, 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 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);
Enter fullscreen mode Exit fullscreen mode

Here's the raw protocol, unedited:

curl -v --http2 -k "https://localhost:8103/demo?hints=1"
Enter fullscreen mode Exit fullscreen mode
< 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
...
Enter fullscreen mode Exit fullscreen mode

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 OFF

Waterfall comparison, Early Hints ON
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
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:

  1. 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).
  2. A render-blocking resource that isn't already discoverable early. The 103 is 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>.
  3. 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 localhost with a self-signed cert, which is a local-tooling detail, not a production concern.
  4. A request path that passes 1xx responses 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 correct 103, and something in front of it never lets it reach the browser. Confirm this with your specific infrastructure before assuming it works.
  5. Stable, non-drifting asset URLs between the 103 and the 200. If your asset URLs are content-hashed or versioned, make sure the version referenced in the 103 matches what the final 200 will 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 103 as 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's Link headers to the final 200's Link/<link> tags exactly, including query strings or hashes.
  • Only apply this to top-level page navigations โ€” a 103 ahead of an API call or client-side fetch() 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 1xx responses 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.

  1. 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 103 ahead of the final < HTTP/2 200. No 103? Stop
    here โ€” the bug is the server or something in front of it (most often a
    proxy/CDN stripping 1xx responses), and nothing downstream will fix that.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. Validate against real traffic before calling it a win. Every step above
    runs in a lab. Ship it behind a flag, split real users with web-vitals or
    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.

Further reading

Top comments (0)