103 Early Hints: The Server Push Replacement That Actually Works

When HTTP/2 Server Push shipped, I was optimistic. The idea of the server pushing critical CSS and fonts before the browser even asked felt like the logical endgame for performance optimization. Five years later, I was disabling it on every property I managed. Chrome removed it, CDNs deprecated it, and the push cache sat unused. The replacement that actually stuck — 103 Early Hints — succeeds precisely because it does less. Instead of pushing bytes, it pushes instructions, and it pushes them early enough to matter. If you run any site where time-to-first-byte is dominated by origin logic, authentication, or edge computation, Early Hints is the only mechanism that lets you use that dead time to start the browser's work. This article covers how it works on the wire, how to deploy it correctly behind a CDN, and how to verify it is actually helping.

Why HTTP/2 Server Push Collapsed Before It Could Scale

In my experience operating high-traffic origins behind a CDN, Server Push failed for operational reasons long before it failed for performance reasons. The core promise was compelling: eliminate the round-trip delay between the browser parsing HTML and discovering critical sub-resources. In practice, the server would speculatively push style.css or app.js alongside the HTML response, saving a full RTT on high-latency connections. On paper, that looked like a 150-300ms win for 3G users. In production, it created more problems than it solved.

The first issue was contention. HTTP/2 multiplexing already allows the browser to request high-priority resources the moment it knows about them. Pushing resources competes for bandwidth with the parent HTML document itself. I saw repeated cases where an over-eager push of a 100KB JavaScript bundle delayed the delivery of the HTML that contained the actual priority signals the browser needed. The server was guessing, and when it guessed wrong, it was worse than doing nothing.

The Push Cache That Browsers Could Not Reliably Use

Server Push relied on a separate push cache that was distinct from the HTTP cache. A pushed resource only lived in that push cache for the connection that pushed it, and it was only usable if the subsequent request was an exact match. Any difference in Accept-Encoding, Accept, or even request header order could cause a mismatch. I debugged several incidents where a pushed font with content-type: font/woff2 was ignored because the browser's real request included an additional sec-fetch-mode header. The origin still paid the cost of sending the bytes, the CDN still paid the egress, and the browser fetched it again anyway.

Chrome's data made the outcome inevitable. Their telemetry showed that pushes were frequently cancelled via RST_STREAM or were for resources the browser already had cached. The complexity of implementing push correctly — handling cache digests, coordinating with service workers, respecting priority signals — was never justified by the hit rate.

How Push Wasted Bandwidth on Already-Cached Assets

The fatal flaw was cache awareness. An HTTP/2 server had no reliable way to know what the browser already had in its HTTP cache. Proposals like Cache Digests (RFC 9510) tried to let the browser advertise its cache contents, but they added overhead and privacy concerns and never saw wide adoption. Without that knowledge, I watched origins push the same 40KB critical CSS file to returning visitors who had it fresh in cache for 30 days. The result was negative performance: wasted bytes on a constrained connection for zero benefit. For any CDN, this was a non-starter. We cannot charge customers for egress that actively harms their Core Web Vitals. That is why every major CDN I have worked with either disabled push by default or removed the toggle entirely by 2022.

How 103 Early Hints Decouples Hint Delivery From Final Response Generation

103 Early Hints, defined in RFC 8297 and updated for HTTP semantics in the HTTP Working Group Specifications, takes the opposite approach. It does not send resources. It sends headers — specifically, Link headers with preloading hints — as an informational response while the final response is still being generated. The browser can act on those hints exactly as if they had arrived in the final 200 response, but hundreds of milliseconds earlier.

The performance model here is different and, in my experience, far more honest. Most dynamic pages have a TTFB of 200ms to 800ms dominated by database queries, API calls, or edge function execution. Without Early Hints, the browser is idle during that entire window. It has an open TCP/TLS/QUIC connection and nothing to do. With Early Hints, the server or edge can flush a 103 response within 20-30ms of receiving the request, well before it knows the full HTML. The browser starts fetching the critical CSS, fonts, and hero images while the origin is still thinking.

The Wire-Level Sequence of a 103 Followed by a 200

A 103 is an informational (1xx) status code, which means it is interim and does not terminate the request. The client must be prepared to receive one or more 103 responses before the final response. Here is what it looks like on the wire for an HTTP/1.1 or HTTP/2 connection:

HTTP/1.1 103 Early Hints
Link: </assets/critical.2a5d.css>; rel=preload; as=style
Link: </assets/inter-var.woff2>; rel=preload; as=font; crossorigin
Link: </assets/hero-1200.webp>; rel=preload; as=image; imagesrcset="/assets/hero-600.webp 600w, /assets/hero-1200.webp 1200w"; imagesizes="100vw"

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 18432
Link: </assets/critical.2a5d.css>; rel=preload; as=style
Cache-Control: private, max-age=0

<!doctype html>
<html>...

There are two things to notice. First, the 103 only contains headers relevant to preloading — most usefully the Link header. It must not contain a body. Second, the final 200 response should still contain the same Link headers. Intermediaries that do not understand 103 will simply strip it and forward only the 200, so including the hints in both places guarantees no loss of functionality. Browsers that understand 103 will start the preload on the first 103 and deduplicate when they see the same hint again in the 200.

What Is Allowed Inside a 103 Response

Per RFC 8297, a 103 response may contain any header field that is allowed to appear in a final response, but in practice you should limit it to headers that are safe to act on speculatively. The useful set is small: Link with rel=preload, rel=preconnect, and rel=modulepreload. Some browsers also act on rel=preconnect inside a 103 to warm up a connection to a third-party origin for fonts or analytics, which can save a DNS+TLS handshake.

Do not put Set-Cookie, Authorization, Content-Security-Policy, or other state-changing or security-critical headers in a 103. The spec advises against it, and browser behavior is inconsistent. I've found that Cloudflare, Fastly, and Akamai will all filter or strip non-Link headers from Early Hints at the edge by default, precisely to avoid any risk of a 103 setting state that a subsequent 200 might try to override. Keep your 103 minimal: just the Link headers you are certain about.

Implementing Early Hints at the Edge: From Origin Configuration to CDN Forwarding

The biggest misconception I encounter is that Early Hints is an origin-only feature. For most sites, the origin is the slowest part of the chain, so if you wait for the origin to emit a 103, you have already lost much of the benefit. The real architecture puts Early Hints generation at the edge, as close to the browser as possible.

There are two deployment models that work reliably. In the first, the CDN automatically generates 103 responses by observing your final Link headers over time — sometimes called automatic or smart Early Hints. Cloudflare has offered this since 2021: it caches the Link headers from your 200 responses and on the next request, sends them as a 103 immediately from the edge, without ever hitting the origin. This requires zero origin changes and is the easiest way to get a win if your critical assets are stable. The second model is origin-driven, where your origin emits the 103 and the CDN is configured to forward it. This is necessary if the set of critical resources varies per page, per user, or per A/B test.

For dynamic pages where the HTML varies but the critical CSS bundle name is stable for a given deploy, I recommend a hybrid: let the edge emit a static 103 for the global assets, and have the origin add page-specific hints in its own 103 or in the final response. Most CDNs will coalesce them and forward both to the browser.

Origin-Side Emission With Code That Survives a CDN

If you need origin control, you must flush the 103 before you start your slow work. In Node.js, for example, you cannot wait for your database promise to resolve. You write the 103 immediately after you know the route:

// Node.js (http module) - send 103 before slow DB/API work
const server = require('http').createServer((req, res) => {
  if (req.url === '/product/123') {
    // Flush 103 immediately - do not await anything before this
    res.writeEarlyHints({
      'link': [
        '</static/critical.2a5d.css>; rel=preload; as=style',
        '</static/inter-var.woff2>; rel=preload; as=font; crossorigin',
        '</static/product-gallery.js>; rel=modulepreload'
      ]
    });

    // Now do slow work: DB lookup, auth, rendering
    renderProductPage(req).then(html => {
      res.writeHead(200, {
        'content-type': 'text/html; charset=utf-8',
        'link': '</static/critical.2a5d.css>; rel=preload; as=style',
        'cache-control': 'private, no-cache'
      });
      res.end(html);
    });
  }
});

The same principle applies in any language. In PHP or Python, you need to disable output buffering for the early flush to actually leave the server. If you are behind Nginx, you need version 1.25.1+ and to ensure proxy_early_hints is on, plus that you are not buffering the entire response in Nginx before forwarding. I have seen countless cases where the origin correctly called writeEarlyHints() but Nginx or Varnish buffered it until the 200 was ready, completely negating the benefit.

# Nginx as reverse proxy - ensure 103s are forwarded, not buffered
server {
  listen 443 ssl http2;
  location / {
    proxy_pass http://origin_app;
    proxy_http_version 1.1;
    proxy_early_hints on;
    proxy_buffering off; # or keep on but ensure early hints still flushed
    add_link $early_hints_link; # if generating at Nginx layer
  }
}

# Alternatively, emit directly from Nginx for static assets
location = /index.html {
  add_header Link "</assets/critical.css>; rel=preload; as=style" early;
  # 'early' flag (nginx 1.25+) sends this Link as 103 before file delivery
}

When you deploy behind a CDN, confirm that HTTP/2 or HTTP/3 to the origin is enabled. Early Hints over HTTP/1.1 works, but over HTTP/2 and HTTP/3 and QUIC: Why UDP-Based Transport Changes Everything the 103 is sent as a HEADERS frame on the same stream, which browsers handle more efficiently and without the connection-level head-of-line blocking that could delay the hint.

How Modern CDNs Forward and Coalesce Early Hints

CDN behavior varies in important ways. Fastly and Cloudflare will by default forward any 103 received from the origin to the client within a few milliseconds. Akamai and AWS CloudFront require explicit configuration to enable the feature in your property or distribution. All of them will also strip the Link headers from the 103 if the client connection does not support 103 — for example, an old HTTP/1.0 proxy in the path. This is why duplicating hints in the final 200 is non-negotiable.

Be careful with edge caching of 103s. If you are using HTTP Caching Headers: Cache-Control, ETag, Vary and Conditional Requests with a Vary header that includes Accept-Encoding or Accept, make sure your Early Hints logic accounts for content negotiation. In my experience, the cleanest approach is to keep your preloaded assets content-agnostic — serve the same CSS and font URLs regardless of negotiation — and use Content Negotiation at the CDN: Accept, Vary and Client Hints only for the HTML document itself. Preloading a Brotli-compressed CSS file that varies by Accept-Encoding will not work; you preload the URL, not the encoding.

103 Early Hints vs. Server Push vs. Link Preload: A Practical Capability Comparison

Teams often ask whether they should replace their existing <link rel="preload"> tags in HTML with Early Hints. The answer is no — they are complementary, and they operate at different points in the timeline. A preload tag in HTML requires the browser to download and parse at least part of the HTML first. A Link header in the final 200 response is slightly earlier, but still blocked on origin TTFB. A Link header in a 103 is the earliest possible signal that can be sent before any HTML bytes exist. The right strategy is to move your most stable, highest-priority preloads into 103, and leave conditional or below-the-fold preloads in HTML or the final headers.

This distinction matters for compression as well. If you are evaluating HTTP Compression: Brotli, Zstandard and Gzip Performance Compared, remember that Early Hints preloads the URL, not the compressed body. The browser will still negotiate compression via Accept-Encoding when it fetches the CSS file. Do not manually append content-encoding variations to your preload URLs.

Capability 103 Early Hints HTTP/2 Server Push Link Preload (200 / HTML)
Delivery timing Before TTFB, while origin computes With or immediately after HTML With HTML or with final headers
What is sent Header hints only Full resource bytes + headers Header hints or HTML tags
Browser cache aware Yes — browser decides to fetch No — server pushes blindly Yes — browser decides to fetch
Can be cancelled cheaply Yes — browser ignores hint No — bytes already sent, RST_STREAM needed Yes — browser ignores hint
Requires HTTP/2 or HTTP/3 No — works over HTTP/1.1 Yes — HTTP/2 only No — works everywhere
CDN intermediary support Forwarded if enabled; safe to ignore Required complex origin+edge+client support Passed through as normal headers/HTML
Chrome / Firefox / Safari status Supported in all current versions Removed from Chrome 106+, never in Safari Universally supported

When to Use Preload Links Without 103 and When to Add Early Hints

If your critical assets change frequently per request — for example, a personalized hero image URL that is not known until after authentication — do not try to force it into a 103. You will either guess wrong or add edge logic that is brittle. Keep that preload in the HTML where you have full context. Use 103 for the assets that are deterministic given just the URL path: the site-wide CSS, the web font, the framework JS chunk. In my audits, those three alone account for 70-80% of the render-blocking chain on most React, Vue, or Next.js storefronts. Getting them started 300-500ms earlier is often enough to move LCP from “needs improvement” into “good” without touching any other optimization.

Also consider rel=preconnect via Early Hints for third-party origins. If your page always loads fonts from https://fonts.gstatic.com or analytics from a known domain, a 103 preconnect can save a full connection setup. This is one of the few cases where a single Link header costs almost nothing and benefits every navigation, including those where the resource itself is already cached.

Debugging and Measuring Early Hints Without Fooling Your Own Metrics

Early Hints is easy to claim and surprisingly easy to mismeasure. The first thing I do on any new deployment is verify that the 103 actually reaches the browser, not just that the origin sent it.

Open Chrome DevTools, go to the Network panel, right-click the header row and enable “Protocol” and “Status”. A successful Early Hints response will appear as a separate row or as an informational entry with status 103 before the 200. More reliably, use curl --verbose with HTTP/2 enabled and look for the interim response:

curl -v --http2 https://www.example.com/product/123 2>&1 | grep -E "< HTTP|< link|< Link"
# Expected:
# < HTTP/2 103
# < link: </assets/critical.css>; rel=preload; as=style
# < HTTP/2 200

If you only see the 200, your CDN is not forwarding the 103. Check your CDN logs for early_hints_sent or equivalent counters, and confirm that you are not stripping Link headers at the edge with a header-remove rule.

For automated verification, WebPageTest now shows Early Hints in the response headers view and will annotate the waterfall when a preload initiated by a 103 starts before TTFB. That visual — a CSS fetch starting while the HTML bar is still in its TTFB phase — is the proof that it is working. If your waterfall still shows the CSS starting after the HTML finishes downloading, your 103 is not being honored.

The Caching and Structured Headers Interactions That Break Early Hints Silently

Two subtle failure modes I see repeatedly deserve attention. The first is caching with Vary. If your origin emits a 103 with Link: </hero.webp>; rel=preload; as=image but your final 200 has Vary: Accept because you serve AVIF/WebP via content negotiation, an intermediary cache might store the HTML and its associated 103 hints under the wrong variant. The next client that negotiates a different format could receive a 103 pointing to an image variant it cannot use. The fix is to avoid varying the HTML on image format when you preload images via Early Hints, or to make the preload URL format-agnostic and let negotiation happen on the image request itself.

The second is the new Structured Headers in HTTP: The New RFC 9651 Format Explained and how Link headers are parsed. The Link header value has a specific grammar with quoted strings and parameters like as, crossorigin, and imagesrcset. A malformed parameter — for example, missing quotes around a URL with spaces or an unescaped semicolon — will cause the browser to discard that hint silently. Chrome will log a warning in the console under “Preload” but will not fail the navigation. I recommend validating your Link headers with a parser rather than string concatenation, especially for responsive image preloads that include imagesrcset and imagesizes attributes.

Finally, measure the right metric. Early Hints improves LCP and FCP by starting high-priority fetches earlier, but it does not reduce TTFB. If you are only watching server response time, you will think it did nothing. Instead, compare LCP before and after in your RUM data, segmented by connection type. In the deployments I have measured, the median LCP improvement is 150-250ms on desktop and 300-600ms on 4G mobile for pages with a 400ms+ TTFB, with no increase in wasted bytes — a trade-off that Server Push could never achieve.

Frequently Asked Questions

Do browsers that don't support 103 Early Hints break or ignore the final response?

No. 103 is an informational status code defined to be safe to ignore. Intermediaries and browsers that do not understand it will simply skip the 103 responses and process the final 200 as if the 103 never happened. That is why you must duplicate your Link headers in both the 103 and the final response — unsupported clients still get the preload hints via the 200 or via HTML tags.

Can I send multiple 103 Early Hints responses for a single request?

Yes. The spec allows one or more 103 responses before the final response. In practice, this is useful if you learn about additional resources as the origin progresses — for example, sending a first 103 for global CSS from the edge, then a second 103 from the origin once it knows which product images are needed. Browsers will process each 103 as it arrives and deduplicate any repeated hints.

Does 103 Early Hints work with HTTP/3 and QUIC?

Yes, and it works especially well there. Early Hints is independent of the transport. Over HTTP/3, the 103 is sent as a HEADERS frame on the QUIC stream exactly as it is over HTTP/2. Because QUIC eliminates TCP head-of-line blocking, the 103 and the subsequent resource fetches it triggers can make progress even if other streams are waiting, which makes the early fetch advantage more consistent on lossy connections.

Should I still use <link rel="preload"> in HTML if I'm already sending 103 Early Hints?

Use both, but for different resources. Put deterministic, render-critical assets like your critical CSS and primary web font in a 103 so they start during TTFB. Keep conditional or request-specific preloads — such as a hero image URL that depends on an API response, or a script needed only for a specific component — as HTML tags where you have full rendering context. You can safely duplicate a hint in both places; the browser will fetch it once.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker