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.
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
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_pushdirective or aLink; 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
103carrying the kept resources asLink: 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
Linkheaders to the final response so cache-cold browsers and edge learners still benefit. - [ ] 6. Verify with
curl. Runcurl -v --http2 https://your-site/routeand confirm a103line appears before the200, with the expectedlink: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.
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
- 103 Early Hints Implementation — the interim-response semantics and hint-set design behind this migration
- Enabling 103 Early Hints on CDN & Origin — the server-by-server steps referenced in the protocol above
- Avoiding Early Hints Cache Poisoning — what gate 3 of the pruning tree is protecting you from