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

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.

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.

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.


Related