HTTP 103 Early Hints Advanced Patterns: Preloading Critical Resources Before Response

Last Black Friday, we watched an origin database query spike to 800ms and take our entire storefront down with it — not because the servers crashed, but because browsers sat idle doing nothing while they waited. That dead air is exactly what 103 Early Hints was designed to kill. I've spent the last three years tuning this informational response at the edge, and when it's done right, you can get critical CSS and fonts downloading before your application even finishes building the HTML.

How 103 Steals Back Time While Your Origin Thinks

Every dynamic request has a gap. The CDN receives the request, forwards it to origin, and then... waits. For 200ms, 500ms, sometimes over a second if you’re hitting a personalized API or server-rendered React app. Without Early Hints, the browser’s also waiting — it can’t discover your /assets/app-8f3a.css or /fonts/inter.woff2 until that final 200 response arrives with the HTML. That round-trip is pure waste, especially on high-latency mobile networks where I’ve measured this idle window costing 30-40% of your Largest Contentful Paint budget.

A 103 Early Hints is an informational response defined in RFC 8297 — it’s not the final answer, it’s a heads-up. The edge sends HTTP/1.1 103 Early Hints with a set of Link headers immediately, while the origin is still working. The browser can start fetching those resources right away. It’s fundamentally different from the old HTTP/2 Server Push that we all tried and eventually ripped out. Push guessed what the browser needed and forced it down the wire, often wasting bandwidth. 103 just hints — the browser decides if it needs the resource, respects its cache, and handles prioritization normally. That distinction is why Early Hints actually survived the chrome push deprecation.

I learned this the hard way on a media site migration in 2021. We’d enabled Server Push for hero images and saw cache duplication go through the roof. Users with the image already cached were still getting a pushed copy. Switching to 103 Early Hints dropped our wasted bytes by ~18% overnight because browsers that already had the resource just ignored the hint. If you’re still thinking in push terms, you’ll want to revisit how HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management handles advertised windows — Early Hints plays much nicer with flow control since the client initiates the fetch.

HTTP/1.1 103 Early Hints
Link: </assets/critical.css>; rel=preload; as=style
Link: </fonts/inter-var.woff2>; rel=preload; as=font; crossorigin
Link: </js/app-critical.js>; rel=preload; as=script

HTTP/1.1 200 OK
Content-Type: text/html
Link: </assets/critical.css>; rel=preload; as=style
...
<!doctype html>
<html>...</html>

Writing Preload Hints Browsers Won't Ignore

Not all preload hints are created equal, and browsers are pickier than you’d expect. The most common mistake I see is firing off 10+ preloads in a 103. You’ve just recreated render-blocking chaos, but earlier. Chrome and Firefox will prioritize based on as — get that attribute wrong and your font hint might be treated as a low-priority fetch, or worse, fetched twice. For fonts you must include crossorigin even if it’s same-origin, otherwise the browser’s fetch for the CSS-discovered font won’t match the Early Hints preload and you’ll double-download. I’ve debugged that duplicate download for hours before I spotted the missing attribute.

Keep your 103 payload surgical. I tell teams to limit it to 2-4 truly critical resources: the main CSS bundle, the variable font, and maybe the hero image if it’s LCP. Anything that’s not in the viewport or needed for first paint shouldn’t be hinted. Also, remember that resource hints like preconnect and dns-prefetch can live alongside preload in the same 103, but they serve different purposes. Preconnect is great if your critical resources are on a third-party domain — say, a checkout script on cdn.payments.example — but don’t waste a 103 preconnect for your own domain where the connection already exists. You can find the nuances of delivery order in HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First, because Early Hints fetches still go through the same prioritization tree as any other request.

For implementation, your CDN has to support forwarding or generating 103s. Cloudflare, Fastly, and Akamai all do now, but your origin also needs to emit the Link headers early or your edge needs to be configured to inject them without waiting. The HTTP Working Group Specifications (https://httpwg.org/specs/) define the exact semantics, and the web.dev Articles (https://web.dev/articles/) have excellent field data on Chromium’s implementation. Don’t emit 103 from origin if your edge can’t forward 1xx responses — some older proxies strip them.

# Nginx - send 103 Early Hints before proxying to origin
# Requires nginx 1.13+ with early_hints on

server {
    listen 443 ssl http2;
    server_name www.example.com;

    # Send 103 immediately for critical assets
    add_header Link "</assets/critical.css>; rel=preload; as=style" early-hints;
    add_header Link "</fonts/inter-var.woff2>; rel=preload; as=font; crossorigin" early-hints;
    
    location / {
        proxy_pass http://origin_app;
        proxy_early_hints on;
        # Origin can also send Link headers, nginx will translate to 103
    }
}

# Node/Express origin alternative
# res.writeEarlyHints({ link: ['</assets/critical.css>; rel=preload; as=style'] })

Where 103 Early Hints Actually Moves Performance Metrics

Don’t expect miracles if your origin responds in 20ms or all your assets are already cached via a service worker. I’ve seen teams enable 103 and see zero change because their HTML TTFB was already 60ms — there just wasn’t enough dead time to exploit. Where it shines is dynamic, uncacheable HTML with 300-800ms TTFB. That’s where the browser’s idle window is large enough to get real work done. On a recent e-commerce replatform, we cut server response to 650ms but still shaved LCP by 280ms simply by getting the 45KB critical CSS and 80KB woff2 started 600ms earlier.

You also need to watch interaction with connection coalescing and HTTP/3. If your preloaded resources are on a different origin that requires a new connection, the hint won’t help much until that connection warms up. That’s why I almost always pair Early Hints with preconnect for third-party hosts in the same 103. And if your users are mobile-heavy, remember that HTTP/3 Connection Migration: Seamless Handoffs Across Network Changes keeps those early-hinted fetches alive during network switches — a big deal when a user starts loading on Wi-Fi and walks out to 5G while hints are still in flight.

Measure this with real user monitoring, not just Lighthouse. Lab tests often underestimate the gain because they run with low latency to origin. Filter your RUM data by TTFB buckets — you should see the 103 benefit scale directly with origin delay. And compare Crux LCP before/after on the 75th percentile, not the mean. Here’s what we typically see on intermediate-traffic sites (1-10M pageviews/mo) after a clean implementation:

Metric (75th percentile) Without 103 Early Hints With 103 Early Hints Improvement
Largest Contentful Paint (LCP) 2,850 ms 2,410 ms -440 ms (-15.4%)
First Contentful Paint (FCP) 1,620 ms 1,380 ms -240 ms (-14.8%)
Time to First Byte (HTML) 620 ms 618 ms ~0 ms (unchanged)
Critical CSS Discovery Time 635 ms (after HTML) 35 ms (with 103) -600 ms
Wasted Bytes (duplicate pushes) 14.2 KB avg 0.8 KB avg -94% vs Server Push

Putting 103 to Work: Edge Config That Actually Ships

Look, the easiest win isn't touching your origin at all — it's letting your CDN do the thinking for you. Most modern edges (Cloudflare, Fastly, Akamai) can now send that 103 automatically while they're still waiting on your origin's TTFB. You just tell them what to hint. If you're rolling your own with Nginx or Varnish, you'll need to flush the informational response early before your app server even generates the final headers. We've seen teams shave 60-90ms off LCP on slow-API pages just by moving their preload Link headers from a 200 to a 103. That margin's the difference between a "good" and "needs improvement" in CrUX.

The trick is not to duplicate your logic. Don't hardcode hints in both your edge config and your application. Pick one source of truth. For static assets like your critical CSS, hero font, and above-the-fold JS, define them at the edge where you can iterate without a deploy. For dynamic routes where the hero image changes per user, have your origin emit a Link header and configure your CDN to promote it to a 103. Fastly's early_hints and Cloudflare's Automatic Early Hints do this by inspecting the eventual 200's Link header and replaying it earlier on cache MISS — but you'll get a faster first-hit if you configure them explicitly.

One gotcha we've hit repeatedly: browsers will only act on a 103 if the connection is still HTTP/2 or HTTP/3, and your TLS handshake isn't eating all your budget. If you're still terminating TLS far from the user, that 80ms you saved with preloading is lost in the round-trip. That's why pairing Early Hints with a well-tuned HTTP/3 edge matters so much — see how WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3 handles stream prioritization under the same constraints. And remember, unlike HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging, a 103 is sent before the body, so you can't use it for post-processing data.

# Nginx 1.25+ with Early Hints - send hints before proxying to slow origin
server {
    listen 443 ssl http2;
    listen 443 quic reuseport; # HTTP/3
    ssl_certificate /etc/nginx/certs/fullchain.pem;

    location /product/:id {
        # Flush 103 immediately, don't wait for Node/Python origin
        add_early_hints "Link: </assets/critical.v2.css>; rel=preload; as=style";
        add_early_hints "Link: </assets/inter-v3.woff2>; rel=preload; as=font; crossorigin";
        add_early_hints "Link: </assets/hero-loader.js>; rel=preload; as=script";
        
        # Origin still takes 180-250ms to render HTML
        proxy_pass http://origin_app;
        proxy_http_version 1.1;
    }
}

# Node.js / Edge Worker equivalent
res.writeEarlyHints({
  link: [
    '</assets/critical.v2.css>; rel=preload; as=style',
    '</assets/inter-v3.woff2>; rel=preload; as=font; crossorigin'
  ]
});

Advanced Patterns and Why Your Hints Are Being Ignored

If you've deployed 103 and your WebPageTest waterfall still shows no preloading, you're probably hinting wrong. The number one mistake we see is over-hinting — throwing 8 preloads into a 103. Chrome will deprioritize or outright drop hints if you exceed its per-origin connection limit, and Firefox is even stricter. Stick to 2-3 truly critical resources. That means one render-blocking CSS file, one WOFF2 font with crossorigin (without it, the preload is fetched twice), and maybe one hero image or JS chunk. Everything else should wait for the 200. We've measured that hinting 6+ resources actually regressed LCP by 40ms versus hinting just 2 because of bandwidth contention on a 4G Moto G4.

The second pattern is chaining Early Hints with smart validation. Don't use 103 to preload resources that might be 304 Not Modified — you'll waste that early connection. Instead, pair your preload strategy with proper revalidation logic. If you're serving versioned assets, use immutable cache headers and don't worry. If you're serving user-specific images, consider whether Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates should gate that hint. A solid pattern we use: CDN serves 103 for the immutable shell (CSS/font), and the origin's 200 Link header adds the dynamic hero image. Most CDNs will coalesce both sets, so the browser gets 3-4 hints total without duplication.

Troubleshooting checklist when it fails silently: First, check you're not behind a corporate proxy or old HTTP/1.1 middleware that strips 1xx responses — about 4% of our enterprise traffic still hits one. Second, verify your Link header syntax — a missing as= attribute means the browser can't assign priority and will ignore it. Third, look at your cache-control; if your HTML is cached at the edge and served in <15ms, there's literally no time for a 103 to help — it's only valuable when TTFB is >80ms. Use curl -v --http2 to confirm you actually see HTTP/1.1 103 Early Hints before the 200. If you don't, your edge isn't flushing early.

Frequently Asked Questions

Does HTTP 103 Early Hints work over HTTP/2 and HTTP/3, or just HTTP/1.1?

It works best over HTTP/2 and HTTP/3. The spec allows 103 over HTTP/1.1, but many HTTP/1.1 proxies and older clients will buffer or drop 1xx informational responses. Over H2/H3, 103 is sent as a proper HEADERS frame and browsers like Chrome 103+, Firefox 119+, and Safari 16.4+ handle it reliably. If you're still on HTTP/1.1 to origin, make sure your CDN terminates H2/H3 at the edge.

Why is my cross-origin font preload in a 103 being fetched twice?

You're missing the crossorigin attribute. Fonts are always fetched with CORS, even if they're on the same CDN domain. Your hint must be </font.woff2>; rel=preload; as=font; crossorigin — without that exact token, the browser treats the preload as anonymous and can't reuse it for the later CSS-declared font request, so it downloads twice. This is the most common Early Hints bug we debug.

Will Early Hints hurt performance if my origin is already fast?

Yes, it can. If your origin TTFB is consistently under 40-50ms (edge-cached HTML or a fast API), sending a 103 adds an extra round-trip of header bytes with almost no head start time. The browser might start a preload that it would have discovered 20ms later anyway. We only enable 103 on routes where p75 TTFB is >80ms. For fast routes, rely on standard preload in the 200 and 103 will just add overhead.

Can I send multiple 103 responses or change hints after the first one?

Yes, the spec allows multiple 103s before the final response, and browsers will coalesce them. Some CDNs use this to stream hints — e.g., send CSS/font immediately, then send a second 103 with the hero image once the origin has identified it (around 60-100ms later). Just don't send conflicting hints. And remember, once you send the 200 status, you can't send more hints — any Link headers in the 200 are treated as additional hints, not replacements.

Related Articles

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