Early Hints and Speculation Rules for Media

Every resource hint discussed elsewhere in the Lazy Loading, Preloading & Fetch Priorities reference lives inside the HTML document, which means none of them can fire until the origin has finished rendering that document and the first bytes of the body have reached the client. HTTP 103 Early Hints and the Speculation Rules API are the two mechanisms that break that ordering constraint: the first lets the server emit Link: rel=preload headers before the 200 response exists, and the second lets the browser fetch — or fully render — the next page before the user has navigated to it. Both are unusually effective for media, because hero images and poster frames are large, discovered late, and sit directly on the Largest Contentful Paint critical path.

This guide covers the wire format of a 103 response, how Chromium consumes it, what the Speculation Rules API actually downloads when it prerenders a media-heavy page, how Cloudflare and Fastly generate hints at the edge, and how the two features interact with fetchpriority and rel=preload once they land in the same document.

Concept & Architecture: What a 103 Response Actually Is

HTTP 103 Early Hints (RFC 8297) is an informational status code. Informational responses — the 1xx class — are not the final response to a request; a client that receives one keeps the request open and waits for the eventual final status. A server may send any number of 103 responses before the 200, each carrying a header block. The only headers with defined meaning in a 103 are Link headers, and the only rel values Chromium acts on are preload, preconnect, modulepreload and dns-prefetch.

The value of the mechanism is entirely a function of origin think time — the interval between the origin receiving the request and being able to write the first byte of the HTML body. On a static file server that interval is a few milliseconds and Early Hints buys you nothing. On a server-rendered application that queries a database, renders a template and calls three internal services, it is routinely 150–600 ms. That whole window is dead air on the connection: the TCP/QUIC connection is established, the TLS handshake is done, congestion window is warm, and the browser is idle. A 103 fills it with the hero image fetch.

The critical architectural detail is that the browser processes the hints on the navigation request’s connection, in the navigation context, before a document exists. There is no DOM, no CSSOM, no preload scanner — the hinted resources go straight into the preload cache keyed by URL, destination, CORS mode and credentials mode. When the 200 arrives and the parser reaches the corresponding <img> or <link rel=preload>, the fetch is already in flight or complete, and the parser adopts the existing record instead of issuing a new request.

The sequence below shows the timing for a page with a 220 ms origin think time and a 180 KB AVIF hero.

103 Early Hints Navigation Sequence A three-lane sequence diagram. The browser sends a navigation request through the CDN edge to the origin. At 40 milliseconds the origin returns a 103 Early Hints response carrying a Link preload header, so the browser starts fetching the hero image at 45 milliseconds. The 200 OK HTML body only arrives at 310 milliseconds, by which time the hero bytes are already complete and LCP paints at 330 milliseconds. Browser CDN edge Origin 0 ms GET / — navigation request 40 ms 103 Early Hints — Link: </hero.avif>; rel=preload; as=image 45 ms GET /hero.avif — 265 ms early 310 ms 200 OK — HTML body begins streaming 330 ms hero bytes already cached → LCP paints Origin think time: 40 ms → 310 ms. Early Hints converts that dead air into hero transfer time.

Three constraints follow directly from this architecture and account for most failed rollouts. First, Chromium only processes Early Hints for main-frame navigation requests — hints attached to a subresource, an iframe navigation, or an XHR are discarded. Second, only the first 103 in a response chain is acted upon; subsequent ones are ignored, so a CDN that appends its own 103 after the origin’s will find its hints dropped. Third, a 103 is not cacheable in its own right — it is replayed by the CDN from a stored association between the URL and the Link headers observed on a previous 200, which is exactly how Cloudflare’s implementation works.

The Speculation Rules API and Its Media Cost

Speculation Rules attack the same latency from the opposite side of the navigation. A <script type="speculationrules"> block declares URLs the browser may fetch (prefetch) or fully render off-screen (prerender) ahead of a click. On activation the prerendered document is swapped into the tab, and paint is effectively instantaneous.

The distinction that matters for media delivery is what each action downloads:

  • prefetch issues a single request for the document HTML with Sec-Purpose: prefetch. It does not run scripts, does not parse for subresources, and does not fetch a single image. Typical cost: 10–40 KB.
  • prerender creates a hidden renderer, parses the document, executes JavaScript, applies CSS and fetches every subresource the page needs — including the full responsive image set the srcset and sizes attributes resolve to for the current viewport. Typical cost on a media-heavy landing page: 400 KB to 2 MB.

That asymmetry is the entire budgeting problem. Prerendering three product pages on a photo-led catalogue can transfer more bytes than the user’s whole session, and the browser will happily do it on a metered connection unless you constrain the rules. Chromium applies its own ceilings — 10 concurrent prerenders and 50 prefetches at immediate/eager eagerness, and 2 of each at moderate/conservative — but those are limits on count, not on bytes, so a small number of heavy pages still blows the budget.

Two more prerender behaviours are specific to media and routinely surprise people. Autoplaying <video> does not start while the document is prerendering; playback is deferred until activation, which is the correct behaviour but breaks any code that measures playback start from DOMContentLoaded. And loading="lazy" images below the fold are not fetched during prerender, because the hidden renderer uses the eventual viewport for intersection calculations — so native lazy loading is the single most effective lever you have for keeping the prerender payload small.

Speculation Rule Lifecycle and Media Payload A branching flow diagram. A parsed speculation rule waits for its eagerness trigger, then splits into two paths: prefetch, which downloads only the document HTML at roughly fifteen kilobytes, and prerender, which downloads the full page including media at roughly four hundred and eighty kilobytes. Both paths converge on activation at navigation. Side notes list the concurrency budget and the trigger conditions for each eagerness level. Rule set parsed Eagerness trigger Concurrency budget immediate / eager: 10 prerender moderate / conservative: 2 Trigger condition moderate: 200 ms hover conservative: pointerdown prefetch — document only no scripts, no images ≈ 15 KB transferred prerender — full document CSS, JS, hero + srcset media ≈ 480 KB transferred Activation on navigation

Benchmark Data

The figures below come from repeated WebPageTest runs against a server-rendered catalogue page: 4G profile at 20 Mbps down and 40 ms RTT, a 180 KB AVIF hero at 1600w, 14 additional subresources, and an origin think time held at 220 ms by a synthetic delay. Each row is the median of nine runs; the byte column is total transfer for the navigation.

Configuration LCP (ms) Hero request start (ms) Bytes on navigation
No hints at all 2 450 640 512 KB
<link rel=preload> in HTML <head> 2 180 395 512 KB
103 Early Hints preloading the hero 1 790 55 512 KB
103 Early Hints + fetchpriority=high 1 660 48 512 KB
Early Hints + preconnect to the media origin 1 605 45 512 KB
Speculation Rules prerender, measured from activation 210 −1 940 (pre-navigation) 512 KB × N speculated

Key finding: Early Hints removed 390 ms of LCP relative to an in-document preload, and essentially all of that came from the hero request starting at 55 ms instead of 395 ms. The subsequent fetchpriority and preconnect refinements are worth another 185 ms combined but are second-order — get the 103 landing first. Prerender is in a different category entirely: it does not make the page faster, it makes the page already loaded, at the cost of transferring the whole payload for every speculated URL whether or not the user clicks.

Median LCP by Hint Configuration Horizontal bar chart. No hints measures 2450 milliseconds, an in-document preload 2180, 103 Early Hints 1790, Early Hints plus fetchpriority 1660, Early Hints plus preconnect 1605, and a prerendered page measured from activation 210 milliseconds. Median LCP — 4G profile, 220 ms origin think time (lower is better) No hints 2450 rel=preload in HTML 2180 103 Early Hints 1790 + fetchpriority=high 1660 + preconnect to media CDN 1605 prerender (from activation) 210 Milliseconds. Prerender is measured from activationStart.

CDN and Server Support Matrix

Platform 103 emission How hints are produced
Cloudflare Yes Harvests Link headers from a previous 200 for the URL, caches them, replays as 103 on subsequent requests. Toggle under Speed → Optimization.
Fastly Yes Explicit h2.early_hints() call in VCL, or a Compute service writing an informational response. Fully programmatic.
Akamai Yes Origin-driven; Link headers on the 200 are promoted to a 103 by the Early Hints behaviour.
AWS CloudFront No native 103 CloudFront does not forward informational responses; use in-document preload or Lambda@Edge-generated Link headers on the 200.
nginx No No 103 support in the mainline HTTP core; terminate at a proxy that can emit one.
Node.js ≥ 18.11 Yes response.writeEarlyHints({ link: [...] }) on both HTTP/1.1 and HTTP/2.
Caddy / Go net/http Yes http.ResponseWriter.WriteHeader(http.StatusEarlyHints) after setting Link.

Warning: Early Hints require HTTPS in every shipping browser implementation, and Chromium additionally ignores 103 over HTTP/1.0. In practice this means HTTP/2 or HTTP/3 over TLS, which is what any CDN-fronted origin already serves. Behind a reverse proxy the informational response must be forwarded verbatim — a proxy that buffers until the final status silently destroys the entire benefit while leaving the 200 intact, so nothing looks broken.

Step-by-Step Implementation

Step 1 — Decide Whether Early Hints Can Help At All

Measure origin think time before writing a line of config. If the origin returns the first HTML byte in under 60 ms there is no window to fill and the added header round trip is pure overhead.

#!/usr/bin/env bash
# Separates connection setup from origin think time. The number that matters is
# (time_starttransfer - time_appconnect): TLS is done, so everything left is the
# server building the response. Bypass the CDN cache to measure the real origin.
URL="https://example.com/"
curl -sS -o /dev/null --http2 \
  -H 'Cache-Control: no-cache' \
  -w 'dns          %{time_namelookup}\ntcp          %{time_connect}\ntls          %{time_appconnect}\nttfb         %{time_starttransfer}\nthink_time   %{time_starttransfer}\n' \
  "$URL"

# Repeat nine times and take the median; a single sample is meaningless on a
# warm/cold cache boundary.
for i in $(seq 1 9); do
  curl -sS -o /dev/null --http2 -H 'Cache-Control: no-cache' \
    -w '%{time_starttransfer} %{time_appconnect}\n' "$URL"
done | awk '{ printf "%.0f ms think time\n", ($1 - $2) * 1000 }' | sort -n

If the median think time is above roughly 100 ms, proceed.

Step 2 — Emit the 103 From the Origin

The origin is the only component that knows which hero asset a given route will render, so it should be the source of truth. Node 18.11+ exposes writeEarlyHints directly on the response object.

// server.mjs — run with: node server.mjs   (HTTPS required in real deployments)
import http from 'node:http';

const HERO = {
  '/':          '/media/home-hero-1600.avif',
  '/catalogue': '/media/catalogue-hero-1600.avif'
};

const server = http.createServer(async (req, res) => {
  const hero = HERO[req.url];

  if (hero) {
    // Emitted BEFORE any database or template work. Each array entry becomes one
    // Link header in the 103 block.
    res.writeEarlyHints({
      link: [
        // as=image is mandatory: without a destination the browser cannot match
        // the preload record to the later <img> and will fetch the file twice.
        `<${hero}>; rel=preload; as=image; fetchpriority=high; ` +
        // imagesrcset/imagesizes must be byte-identical to the <img> attributes,
        // otherwise the candidate the parser selects may not be the one preloaded.
        `imagesrcset="${hero.replace('1600', '800')} 800w, ${hero} 1600w"; ` +
        `imagesizes="(max-width: 800px) 100vw, 1600px"`,
        // Warm the TLS connection to the media CDN for everything not hinted.
        '<https://media.example.com>; rel=preconnect; crossorigin'
      ]
    });
  }

  // ---- expensive work happens here: 220 ms of queries and rendering ----
  const html = await renderPage(req.url);

  res.writeHead(200, {
    'content-type': 'text/html; charset=utf-8',
    // Repeat the same Link header on the 200 so CDNs that harvest hints from the
    // final response (Cloudflare) can learn and replay them.
    link: hero ? `<${hero}>; rel=preload; as=image` : ''
  });
  res.end(html);
});

async function renderPage(url) {
  await new Promise(r => setTimeout(r, 220)); // stand-in for real render cost
  return `<!doctype html><html><head><title>Demo</title></head><body>
    <img src="/media/home-hero-1600.avif"
         srcset="/media/home-hero-800.avif 800w, /media/home-hero-1600.avif 1600w"
         sizes="(max-width: 800px) 100vw, 1600px"
         fetchpriority="high" width="1600" height="900" alt="Hero">
  </body></html>`;
}

server.listen(8080);

Step 3 — Enable Replay at the Cloudflare Edge

Cloudflare does not invent hints; it caches the Link headers it sees on a 200 and replays them as a 103 for later requests to the same URL. That means Step 2’s duplicate Link header on the final response is a prerequisite, not a nicety.

#!/usr/bin/env bash
# Enable Early Hints for a zone via the API. ZONE_ID and CF_API_TOKEN come from
# the environment; the token needs Zone Settings:Edit.
curl -sS -X PATCH \
  "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/settings/early_hints" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H 'Content-Type: application/json' \
  --data '{"value":"on"}' | jq '.result'

# Verify the replay is live. --http2 is required: informational responses are not
# surfaced by curl over HTTP/1.1 in older builds, and 103 is HTTPS-only anyway.
curl -sSI --http2 https://example.com/ 2>&1 | grep -iE '^(HTTP/2 103|HTTP/2 200|link:)'
# Expect:
#   HTTP/2 103
#   link: </media/home-hero-1600.avif>; rel=preload; as=image
#   HTTP/2 200

Note that the first request to a cold URL sees no 103 — Cloudflare must observe a 200 first. Full walkthrough including cache-key interactions and the cf-cache-status semantics is in enabling 103 Early Hints on Cloudflare for hero images.

Step 4 — Emit Hints Programmatically From Fastly VCL

Fastly is the opposite model: nothing is harvested, you call h2.early_hints() explicitly and it fires immediately, before the backend is even contacted. That makes it the better fit when the hero URL is derivable from the request path.

sub vcl_recv {
  #FASTLY recv

  # Only main-document navigations benefit; hinting on subresources is discarded
  # by the browser and wastes header bytes.
  if (req.method == "GET" && req.http.Accept ~ "text/html") {

    if (req.url.path == "/") {
      # Each call adds one header line to the 103 block. Fires before the
      # backend fetch, so it costs zero origin latency.
      h2.early_hints("link: ; rel=preload; as=image; fetchpriority=high");
      h2.early_hints("link: ; rel=preconnect; crossorigin");
    }

    # Derive the hero from the route for templated pages.
    if (req.url.path ~ "^/product/([a-z0-9-]+)/?$") {
      h2.early_hints("link: ; rel=preload; as=image");
    }
  }
}

Warning: h2.early_hints() fires unconditionally, including on requests that will end in a 404 or a 302. A redirect that carries a preload hint for the old page’s hero downloads bytes the user will never see. Gate the calls on route patterns you know resolve to a rendered document, exactly as above. The same header-manipulation discipline covered in Fastly VCL for image format negotiation applies here.

Step 5 — Add Speculation Rules With a Media Budget

Rules go in a <script type="speculationrules"> block, or in a JSON document referenced by the Speculation-Rules response header. Use document rules with where clauses so links are matched dynamically rather than enumerated.

<script type="speculationrules">
{
  "prerender": [{
    "source": "document",
    "where": {
      "and": [
        { "href_matches": "/product/*" },
        // Exclude anything that would blow the byte budget or mutate state.
        { "not": { "href_matches": "/product/*/gallery*" } },
        { "not": { "selector_matches": "[data-heavy-media]" } },
        { "not": { "selector_matches": "[rel~=nofollow]" } }
      ]
    },
    // moderate = fire after ~200 ms of hover. Cheapest signal that still
    // wins the perceived-latency race, and capped at 2 concurrent prerenders.
    "eagerness": "moderate"
  }],
  "prefetch": [{
    "source": "document",
    "where": { "href_matches": "/catalogue/*" },
    // Document-only fetch: safe to be more aggressive because no media moves.
    "eagerness": "eager",
    // Treat these query params as not affecting the response, so a prefetch of
    // ?utm_source=x can still serve a click on the clean URL.
    "expects_no_vary_search": "params=(\"utm_source\" \"utm_medium\")"
  }]
}
</script>
<script>
  // Withdraw prerendering on constrained networks. Rule sets are inert once
  // removed from the DOM, so deleting the node cancels pending speculations.
  const conn = navigator.connection;
  if (conn && (conn.saveData || /2g/.test(conn.effectiveType))) {
    document.querySelectorAll('script[type="speculationrules"]').forEach(n => n.remove());
  }
</script>

The concurrency ceilings, byte accounting, and how to keep a prerendered media page under a fixed budget are worked through in speculation rules prerender and the media prefetch budget.

Step 6 — Make the Prerendered Page Media-Safe

A prerendered document runs its JavaScript before the user has committed to visiting it. Analytics fire early, autoplay is deferred, and any media player that starts buffering on load will pull segments for a page that may never be shown.

// Gate expensive media work until the document is actually activated.
function onActivated(fn) {
  if (document.prerendering) {
    // Fires exactly once, when the hidden renderer is swapped into the tab.
    document.addEventListener('prerenderingchange', fn, { once: true });
  } else {
    fn();
  }
}

onActivated(() => {
  // Start the HLS/DASH player only now — segment requests during prerender are
  // pure waste if the click never comes.
  document.querySelectorAll('video[data-src]').forEach(v => {
    v.src = v.dataset.src;
    v.load();
  });
  sendPageView();
});

// Server side: skip heavy media rendering for speculative requests entirely.
// Sec-Purpose: prefetch  → document-only prefetch
// Sec-Purpose: prefetch;prerender → full prerender
// Respond 503 to opt a route out of speculation altogether.

Step 7 — Measure the LCP Delta Correctly

For a prerendered page the clock the browser reports and the clock the user perceives are different. PerformanceNavigationTiming.activationStart is the offset at which activation happened; every timing before it happened while the page was invisible and must be subtracted.

// Reports the LCP the user actually experienced, correct for both normal and
// prerendered navigations, and tags each beacon with the hint path that produced it.
const nav = performance.getEntriesByType('navigation')[0];
const activationStart = nav?.activationStart ?? 0;

new PerformanceObserver(list => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1];

  // Clamp at 0: on a prerendered page LCP often occurred BEFORE activation.
  const lcp = Math.max(0, last.startTime - activationStart);

  // Did the hero come from an Early Hints preload? initiatorType 'early-hints'
  // is reported by Chromium for resources started by a 103.
  const hero = performance.getEntriesByName(last.url || '')[0];

  navigator.sendBeacon('/rum', JSON.stringify({
    lcp: Math.round(lcp),
    element: last.element?.tagName,
    prerendered: activationStart > 0,
    initiator: hero?.initiatorType,          // 'early-hints' | 'img' | 'link'
    renderDelay: Math.round(last.startTime - (hero?.responseEnd ?? last.startTime))
  }));
}).observe({ type: 'largest-contentful-paint', buffered: true });

Field-level attribution — separating the population that received a 103 from the population that did not — is covered in measuring the LCP impact of Early Hints, and the aggregate view over 28 days comes from the CrUX API.

Parameter Reference

Parameter Values Notes
rel preload, preconnect, modulepreload, dns-prefetch Anything else in a 103 is ignored by Chromium. prefetch is not processed from Early Hints.
as image, video, audio, font, style, script, fetch Mandatory for preload. Wrong or missing as produces a duplicate fetch and an unused-preload console warning.
imagesrcset / imagesizes srcset / sizes syntax Must match the <img> attributes exactly, including whitespace-normalised descriptors, or the browser preloads a candidate it will not use.
fetchpriority high, low, auto Chromium honours this in Link headers; other engines ignore it harmlessly.
crossorigin present, anonymous, use-credentials Must match the element’s CORS mode. A mismatch partitions the cache and fetches the asset twice.
media media query Evaluated against the viewport before the document exists — safe, because the browser already knows the window size.
type MIME type Lets the browser skip a hint for a format it cannot decode, e.g. type="image/avif" on a legacy engine.
nopush flag Legacy HTTP/2 push suppressant. Harmless, and irrelevant now that push is removed from Chromium.

Speculation rules fields

Field Values Notes
source list, document list enumerates urls; document matches links in the page via where.
eagerness immediate, eager, moderate, conservative immediate fires on parse; eager on any hover intent; moderate after ~200 ms hover; conservative on pointerdown.
where href_matches, selector_matches, and, or, not URL patterns support * wildcards and named groups; selectors are matched against the anchor element.
requires anonymous-client-ip-when-cross-origin Only meaningful for cross-origin prefetch through a privacy-preserving proxy.
referrer_policy any Referrer-Policy token Applied to the speculative request; a stricter policy than the document’s is required for some cross-origin cases.
expects_no_vary_search No-Vary-Search header syntax Declares which query parameters do not change the response, letting one speculation satisfy several URLs.
tag free string Labels the rule set; surfaced in the Sec-Speculation-Tags request header for server-side attribution.

Tradeoffs & Edge Cases

Tradeoff: Early Hints spends bandwidth on a guess, and the guess is per-URL, not per-user. The hinted hero is fetched before the origin has decided anything — including whether the user is authenticated, A/B bucketed, or about to be redirected. If half your traffic on / is bucketed into a variant with a different hero, half your Early Hints traffic is downloading an image that is never painted. Hint only assets that are invariant across every variant of the route, or move hint generation into an edge worker that can read the bucketing cookie before emitting the 103.

Tradeoff: prerender is a bandwidth multiplier with a hit-rate denominator. A prerender that converts is close to free from the user’s perspective; one that does not converts a 480 KB page load into pure waste. At moderate eagerness on a typical catalogue, hover-to-click conversion runs around 45–65%, so expect to transfer roughly 1.7× your normal media bytes for the pages under rules. On a photo-heavy site that is the difference between a 3 MB and a 5 MB session — audit it against real numbers before shipping, and prefer prefetch for anything whose value is mostly the HTML.

Warning: a mismatched crossorigin between the 103 hint and the element double-fetches the hero. This is the single most common Early Hints bug and it is invisible in aggregate metrics: LCP does not regress, bytes silently double. The preload record is keyed by URL plus destination plus CORS mode plus credentials mode. Link: </hero.avif>; rel=preload; as=image with a matching <img crossorigin="anonymous"> in the document produces two requests, because the hint’s mode is no-cors and the element’s is cors. Either add crossorigin to the hint or remove it from the element.

Edge case: hints replayed from cache go stale after a deploy. Cloudflare’s harvest-and-replay model stores the Link headers it observed on an earlier 200. Ship a new hashed hero filename and the edge keeps replaying the old URL until the stored association is purged, preloading a 404 on every navigation. Purge Early Hints alongside HTML on deploy, and keep the hinted asset URLs on a Cache-Control: immutable policy consistent with the rest of your cache-control strategy for media assets.

Edge case: Sec-Purpose handling determines whether prerender corrupts your analytics. A prerendered page executes analytics, ad, and consent scripts before the user has consented to anything by clicking. Chromium does defer some APIs, but a plain fetch() in a tag manager fires immediately. Every third-party script on a prerendered route needs auditing behind the document.prerendering gate from Step 6, or your funnel gains a large population of pageviews with zero engagement.

Edge case: Early Hints and <link rel=preload> in the document are complements, not alternatives. Keep both. The 103 serves the population on browsers and CDN paths that support it; the in-document preload covers everyone else, including Safari on connections that skip informational responses, and costs nothing extra when the hint already won — the parser adopts the in-flight record rather than issuing a second request. The same reasoning applies to rel=preconnect for CDN media origins: hint it in the 103 and keep the tag.

Choosing Between Early Hints and Speculation Rules A decision tree with three diamond-shaped questions. If origin think time is under one hundred milliseconds, skip Early Hints and keep an in-document preload. If the hero URL is not stable per route, compute hints in an edge worker. If the next navigation is not highly predictable, use Early Hints alone. Only when all three conditions hold should prerender with moderate eagerness be added. Origin think time > 100 ms? no Skip Early Hints — no window to fill. Keep rel=preload. yes Hero URL stable per route? no Compute the hint at the edge from cookie or path groups yes Next click predictable? no 103 Early Hints only — no speculation rules yes Add prerender, eagerness moderate, lazy below-fold media

Debugging & Validation

Confirm the 103 is on the wire

curl prints informational responses when you ask for the full header trace. If you see only the 200, either the origin is not emitting, or something between it and you is swallowing the response.

# -v over HTTP/2 shows the 103 frame explicitly. grep keeps the output readable.
curl -sSv --http2 https://example.com/ -o /dev/null 2>&1 \
  | grep -iE '< HTTP/2 (103|200)|< link:'

# HTTP/3 path — worth checking separately, some edges only implement 103 on h2.
curl -sSv --http3 https://example.com/ -o /dev/null 2>&1 | grep -iE '103|link:'

# Confirm the hinted asset actually exists. A hint pointing at a 404 costs a
# round trip on every navigation and never shows up in LCP metrics.
HINT=$(curl -sSI --http2 https://example.com/ | grep -i '^link:' \
        | sed -E 's/.*<([^>]+)>.*/\1/' | head -1)
curl -sS -o /dev/null -w '%{http_code} %{size_download} bytes  %{url_effective}\n' \
  "https://example.com${HINT}"

Verify the browser used the hint, not a second request

In Chrome DevTools → Network, enable the Initiator column. A resource fetched from a 103 reports its initiator as early-hints. Two rows for the same URL means the preload record did not match — check crossorigin, as, and whether imagesrcset selected a different candidate.

// Paste into the console after a reload. Any URL appearing twice is a
// duplicate fetch caused by a hint/element mismatch.
const byUrl = {};
performance.getEntriesByType('resource').forEach(e => {
  (byUrl[e.name] ??= []).push(e.initiatorType);
});
Object.entries(byUrl)
  .filter(([, inits]) => inits.length > 1)
  .forEach(([url, inits]) => console.warn('DUPLICATE', url, inits));

// Confirm which resources the 103 actually started.
performance.getEntriesByType('resource')
  .filter(e => e.initiatorType === 'early-hints')
  .forEach(e => console.log('from 103:', e.name, Math.round(e.responseEnd), 'ms'));

Inspect speculation state

Chrome DevTools ships a dedicated Application → Speculative loads panel listing every rule set, every candidate URL, and — critically — the reason a speculation was rejected. MemoryLimitExceeded, DataSaverEnabled, MaxNumOfRunning...PrerendersExceeded and TriggerDestroyed account for the large majority of silent no-ops. chrome://net-export/ capture replayed in the netlog viewer gives the same information at frame level when the panel is not enough.

# Headless check that the rule block parses. An invalid JSON body is dropped
# silently — no console error, no panel entry.
curl -sS https://example.com/ \
  | python3 -c "
import sys, re, json
html = sys.stdin.read()
for m in re.finditer(r'<script type=\"speculationrules\">(.*?)</script>', html, re.S):
    try:
        rules = json.loads(m.group(1))
        print('OK', list(rules.keys()), [r.get('eagerness') for v in rules.values() for r in v])
    except json.JSONDecodeError as e:
        print('INVALID speculation rules:', e)
"

Prove the LCP delta

Run the same URL twice through WebPageTest with the Early Hints setting toggled, and diff the filmstrips rather than trusting a single LCP number — the WebPageTest filmstrip diff automation workflow does this on a schedule. For lab confirmation of the request-start shift, the DevTools Performance panel’s Network track is decisive: with a working 103 the hero bar begins before the HTML bar ends, which is impossible for any in-document hint.