HTTP/2 Server Push vs 103 Early Hints

If your build still generates HTTP/2 PUSH_PROMISE frames to accelerate first paint, you are shipping configuration that every current browser now ignores — and the job it was meant to do belongs to the 103 Early Hints interim response instead.

Root cause: push guessed for the client, Early Hints lets the client decide

HTTP/2 server push let the origin send a resource the client had not asked for: alongside the HTML response, the server opened extra streams carrying the stylesheet, the font, the script, each announced by a PUSH_PROMISE frame. On paper it removed a full round trip — the assets arrived before the browser’s parser had even discovered them. In practice it failed for one structural reason: the server pushed blind to the client’s cache. The browser is the only party that knows what is already in its HTTP cache, its memory cache, or its module map. A server pushing app.css to a returning visitor who already had app.css cached spent bandwidth and a stream on bytes the browser then discarded — and worse, the push often arrived ahead of the browser’s chance to send a RST_STREAM cancel, so the waste was already on the wire.

The failure is sharper at the frame level. Push is announced, not requested: the server emits a PUSH_PROMISE on the stream already carrying the HTML, and that frame reserves an even-numbered stream — client-initiated requests always take odd numbers — before any HEADERS or DATA for the promised resource appear. On the client side that stream enters the reserved (remote) state, and the client’s only lever is RST_STREAM with CANCEL. The lever is slow in the way that matters: the cancel travels one full leg toward the server while the promised DATA frames are already travelling the other way. On a 60 ms round trip a server will have written most of a 40 KB stylesheet before the cancel lands, and those frames have already drawn down the connection-level flow-control window — the same window the HTML response is trying to spend.

What the browser does with a PUSH_PROMISE, and the three ways it ends A PUSH_PROMISE frame sent on the HTML stream reserves an even-numbered stream, which the browser holds in the reserved remote state before its parser has seen the URL. From there the stream resolves three ways: branch A, a cache miss the HTML really references, which saves about one round trip; branch B, a cache hit where the browser sends RST_STREAM CANCEL but the pushed bytes are already in flight and are discarded; branch C, current Chromium, which refuses every promised stream outright. Because the server chose the contents from the URL alone, branch B was as likely as branch A. One pushed stream, three endings — and only one of them helped Stream states as the browser sees them, for a 40 KB stylesheet pushed on a 60 ms round trip. PUSH_PROMISE sent on stream 1 with the HTML; reserves even-numbered stream 4 reserved (remote) browser holds the promise; the parser has not seen the URL yet A · cache miss, parser uses it the branch push was built for 1 RTT saved B · cache hit, browser cancels RST_STREAM races the DATA ~34 KB on the wire, binned C · Chromium 106 and later promise refused outright every push, always Why branch A was always a gamble The server picked stream 4's contents from the URL alone. It could not see the browser's HTTP cache, memory cache or module map, so B was as likely as A. Chromium settled the argument by turning every branch into C: it now sends SETTINGS_ENABLE_PUSH as 0 on every connection it opens.

The problems compounded. Push interacted badly with stream prioritization: pushed streams competed with the HTML itself for connection bandwidth, so an over-eager push config could delay the very document it was trying to accelerate — a priority inversion the author never asked for. Push was also hard to reason about across a CDN, where the edge and origin disagreed about what had been pushed. By the time the ecosystem measured it at scale, push was as likely to hurt as help, and Chromium removed it in version 106.

103 Early Hints, defined in RFC 8297, keeps the one genuinely good idea — use the server’s think-time to start critical fetches early — and hands the decision back to the client. Instead of pushing bytes, the server flushes a header-only interim response carrying Link: rel=preload and rel=preconnect directives. The browser reads them, checks its own cache, and initiates the fetches it actually needs. The cache-blindness that doomed push simply cannot occur, because the party doing the cache lookup is the party that owns the cache.

The difference in one timeline

Server push vs Early Hints byte flow Top lane, server push: during the request the server pushes the stylesheet and font on extra streams; for a returning visitor those bytes are discarded because the browser already had them cached, and the push contended with the HTML stream. Bottom lane, Early Hints: the server flushes a tiny 103 with Link headers; the browser checks its cache, fetches only the uncached font, and the discarded-bytes case does not arise. HTTP/2 server push (returning visitor) HTML render pushed app.css — discarded (cached) pushed font — discarded (cached) ← wasted bytes + stream contention 103 Early Hints (returning visitor) 103 (≈200 B Link headers) server think-time browser cache lookup fetch only uncached font ← app.css skipped, zero waste 200 HTML render time →

Minimal reproduction: the Early Hints replacement

Wherever your server configuration currently declares a push list, the equivalent is a 103 response carrying the same resources as Link headers, followed by the final 200 that mirrors them. Expressed as the raw response bytes on one HTTP/2 connection:

HTTP/2 103 Early Hints
link: </css/app.css>; rel=preload; as=style
link: </fonts/inter.woff2>; rel=preload; as=font; crossorigin
link: <https://img.cdn.example>; rel=preconnect

HTTP/2 200 OK
content-type: text/html; charset=utf-8
link: </css/app.css>; rel=preload; as=style
link: </fonts/inter.woff2>; rel=preload; as=font; crossorigin
<!doctype html>…

The Link headers are repeated on the 200 on purpose: a browser that ignored the interim response still acts on the final one, and edge platforms that learn hint sets read them from the final response. The browser deduplicates — a resource already fetched because of the 103 is not fetched again for the identical 200 hint. The crossorigin on the font preload matters for the same reason it always does: without it the interim-hinted fetch and the CSS-triggered fetch key to different cache entries and the font downloads twice.

The mechanical difference from push shows up in where the config lives. Push was a server directive that named files; Early Hints is a response the application has to flush before it starts working. In Node that is one call placed above the slow await, not below it:

app.get('/p/:id', async (req, res) => {
  res.writeEarlyHints({
    link: [
      '</css/app.css>; rel=preload; as=style',
      '</fonts/inter.woff2>; rel=preload; as=font; crossorigin',
      '<https://img.cdn.example>; rel=preconnect',
    ],
  });
  const page = await loadPage(req.params.id);   // ~500 ms of think-time
  res.writeHead(200, {
    'content-type': 'text/html; charset=utf-8',
    link: '</css/app.css>; rel=preload; as=style',
  });
  res.end(render(page));
});

Two things break this silently. The first is a reverse proxy or framework layer that buffers the response until the final status is known — the interim response then leaves the origin at the same moment as the 200 and buys nothing. The second is calling writeEarlyHints after the database work, which is the same mistake wearing different clothes. Verify with a timestamped curl, not by reading the code: the 103 line should appear roughly a network round trip after the request, and the 200 half a second later.

Deterministic migration protocol

Move from a push configuration to Early Hints one step at a time, verifying at each stage that you have not regressed.

  • [ ] 1. Inventory the current push list. Extract every resource your server or CDN currently pushes (often an http2_push directive or a Link; rel=preload; nopush-adjacent config). This list is your candidate hint set.
  • [ ] 2. Prune to render-critical only. Push lists tend to accrete. Keep only resources needed for first paint — the render-blocking stylesheet, the LCP image, the critical font. Everything else belongs in markup or prefetch, not in the hint set.
  • [ ] 3. Disable server push. Remove the push directives. Confirm in the Network panel that no request shows “Push / Other” as its initiator — current browsers ignore push, so this only removes dead configuration and its origin-side cost.
  • [ ] 4. Emit the 103 with the pruned list. Configure the origin or edge to flush a 103 carrying the kept resources as Link: rel=preload/rel=preconnect. See enabling Early Hints on CDN and origin for the server-specific steps.
  • [ ] 5. Mirror the hints on the 200. Add the identical Link headers to the final response so cache-cold browsers and edge learners still benefit.
  • [ ] 6. Verify with curl. Run curl -v --http2 https://your-site/route and confirm a 103 line appears before the 200, with the expected link: headers on both.
  • [ ] 7. Confirm dedup and no unused preloads. Load the route in DevTools; each hinted resource should fetch once and be consumed. An unused-preload warning means an over-broad hint survived step 2.

Step 2 is where migrations succeed or fail, and it is not a judgement call. Every entry on the old push list goes through the same three gates, in order, and anything that falls out at a gate has a defined home somewhere other than the 103.

Three gates that turn a five-entry push list into a two-entry hint set Each resource from the old push list passes three questions in order. Gate one asks whether it is render-blocking or the largest contentful paint element; a no drops it from the hint set, which is where app.js, sprite.svg and analytics.js stop. Gate two asks whether the URL is same-origin and stably hashed; a no means hint the origin with preconnect rather than preloading the file. Gate three asks whether every variant of the route references it; a no means key the hint per variant or leave it out. Only app.css and inter.woff2 clear all three and are emitted as Link preload headers. Pruning the push list: three gates, in order Run every entry of the old http2_push list through all three before it earns a slot in the 103. 1 · Render-blocking, or the LCP element? Only first-paint blockers earn a slot in the 103. Anything the parser can wait for stays in markup. 2 · Same-origin, with a stable hashed URL? A hint aimed at a URL that rotates per deploy is a guaranteed miss and a doubled download. 3 · Used by every variant of this route? Locale or device splits break a shared hint set the moment an edge starts caching it. yes yes yes No → drop it from the hint set entirely app.js, sprite.svg and analytics.js stop here: the parser finds them and they never block paint. No → hint the origin, not the file rel=preconnect costs one connection setup and survives a filename you cannot predict. No → key the hint, or leave it out a per-variant asset hinted globally is the classic poisoned-hint bug, served to every visitor. Keep it — emit it in the 103 rel=preload with the matching as= token The worked list: 5 pushed → 2 preloaded app.css and inter.woff2 clear all three gates; the other three never reach gate 2.

Gate 3 is the one teams skip, and it is the one that bites at the edge rather than at the origin, because a learned hint set is keyed more coarsely than the response cache it sits in front of — the failure mode covered in avoiding Early Hints cache poisoning.

Where Early Hints still misses

Early Hints is narrower than push was, deliberately, and the boundaries are worth knowing before you attribute a missing fetch to a broken config.

It is a navigation feature. Chromium acts on a 103 only for main-frame navigation requests. Hints attached to a subframe navigation, an fetch() call, or any subresource response are parsed and discarded. If you are hinting from an API route hoping to warm the next page, that is speculation-rules territory, not Early Hints.

Only two rel values do work. preload and preconnect are the directives browsers act on in an interim response. Other link relations in the same header are ignored rather than rejected, so an unhandled rel=dns-prefetch in the 103 looks correct in curl and does nothing in the browser.

Redirects throw the hints away. A 103 emitted ahead of a response that turns out to be a 301 is wasted work: the navigation moves to a new URL and the hinted fetches, if they even started, belong to the old one. Emit hints from the URL that actually serves HTML, which usually means after canonical redirects have been applied at the edge.

Engine support is uneven. Chromium has acted on navigation Early Hints since version 103. Firefox added rel=preload handling in an interim response later, in the version 120 timeframe, and WebKit has been the slowest to move. Because the hint is purely additive, the cost of a browser that ignores it is zero — the mirrored Link headers on the 200 still apply — which is why the migration is safe to ship without a support matrix in front of you.

HTTP/1.1 is a practical no. Interim responses are legal in HTTP/1.1, but the installed base of intermediaries that mishandle a response with two status lines is large enough that browsers restrict the feature to HTTP/2 and HTTP/3 connections. If curl shows the 103 and the browser ignores it, check nextHopProtocol before you check anything else.

Before/after metrics

Measured on a dynamic route with ~500 ms origin think-time serving a render-blocking stylesheet and a critical font, on a simulated 4G link. “Push” is the legacy config as seen by a current browser (push ignored); “Early Hints” applies the migration.

Metric Server push (current browser) 103 Early Hints Change
Critical CSS start time after HTML parse (~560 ms) during think-time (~60 ms) −500 ms
Largest Contentful Paint 3.1 s 2.3 s −0.8 s
Wasted pushed bytes (returning visitor) up to full asset size 0 eliminated
Extra streams contending with HTML 2 0 −2
First Contentful Paint 1.9 s 1.4 s −0.5 s

With push ignored by the browser, the legacy config delivered none of its intended head start — the stylesheet was still discovered only at parse time. Early Hints recovers the think-time overlap that push was originally chasing, without the cache-blind waste that got push removed in the first place.

One number in that table is worth reading twice. The critical CSS start time moves by roughly the whole think-time, not by a round trip, because the win is overlap, not transfer speed. It follows that the size of your gain is set by how slow your origin is: a route that renders in 40 ms has almost nothing for the hint to overlap with, while a route that spends 800 ms in a database has 800 ms of free download time on offer. Measure the think-time first; if it is small, the migration is still worth doing to delete the dead push config, but do not expect the LCP move.

FAQ

Is HTTP/2 server push still usable in any browser?

No. Chromium removed support for HTTP/2 server push in version 106, and other major browsers had already dropped or never shipped meaningful support. A PUSH_PROMISE frame sent to a current browser is ignored and the pushed stream is refused or cancelled. Because push required no client opt-in, servers can keep emitting it harmlessly, but no browser will act on it — so it delivers no benefit.

Does 103 Early Hints work over HTTP/3?

Yes. Early Hints is a status code, not a transport feature, so it rides on HTTP/2 and HTTP/3 the same way. Interim responses are part of the HTTP semantics both versions share. In practice it is used over HTTP/2 and HTTP/3 rather than HTTP/1.1, because interleaving an interim response with the eventual final response on one connection is cleanest under multiplexing.

Can Early Hints waste bytes the way push did?

Much less easily. Because the browser initiates the fetch, it first checks its own cache and skips anything already stored — precisely the check push could not perform. The residual waste case is hinting a resource the page does not actually use, which surfaces as an unused-preload console warning and is caught in review, not silently pushed onto every visitor.

Is there anything server push could do that Early Hints cannot?

Two things, both narrow. Push delivered bytes without a client request at all, so it saved the request leg as well as the discovery leg; Early Hints only removes discovery time, and the hinted fetch still costs its own round trip from the browser. Push could also prime a cache with a resource the current page never referenced — a genuinely server-initiated transfer with no client-side equivalent. Neither capability justified the cache-blind waste in a browser, and both remain available server-to-server, where push is still specified and still used.

What happens if the origin returns a 404 or 500 after the 103 has gone out?

The hinted fetches have already started and cannot be recalled. Whatever downloaded stays in the browser’s HTTP cache subject to the assets’ own Cache-Control, and the error page simply does not use it. This is the practical argument for hinting only resources shared across a route’s outcomes: the global stylesheet and the shell font are safe, an asset that exists only on the success path is not.

Can a server send more than one 103 for the same request?

Yes. RFC 8297 allows any number of interim responses before the final one, so an edge can flush a cached hint set immediately and the origin can append a route-specific hint later in the same exchange. Each 103 is processed as it arrives, the sets are additive, and the browser deduplicates repeated URLs. Handling of the second and later hint response is less consistently exercised than the first, so treat the initial 103 as the one that must carry the render-critical set.


Related