Resource Hints & Early Hints: Buying Back the Discovery Delay

This guide extends the delivery layer described in Advanced Caching Strategies & CDN Architecture to the problem a cache cannot solve: a resource the browser has not discovered yet is not being fetched, no matter how close to the user it is stored.

Every subresource on a page has a discovery timestamp — the moment the browser learns it exists — and a request timestamp. The gap between them is pure waste, and on a modern SPA-ish page it is routinely 400–900ms, because the fonts, the hero image and the route chunk are all referenced from a CSS or JavaScript file that must itself be downloaded and parsed first. Resource hints close that gap by telling the browser about a resource before the parser reaches it, and Early Hints goes further by sending those hints before the HTML response has even been generated.

Discovery time with no hints, with preload, and with 103 Early Hints Three stacked timelines show the LCP image request starting progressively earlier: after CSS parse with no hints, alongside the HTML with a preload, and during server think time with Early Hints. When does the hero image start downloading? No hints server HTML CSS hero image LCP here preload server HTML hero image CSS ≈180ms earlier 103 Early Hints server hero image HTML starts inside server think time request sent

Used well, hints are the cheapest LCP improvement available. Used carelessly, they are a way to make a page slower while feeling productive: every hint competes for the same connection, and a preload that fires ahead of the LCP image has actively harmed the metric it was meant to help.

The Five Hints and What Each One Actually Does

Each hint operates at a different layer, and confusing them is the root of most misuse.

HintWhat it doesCosts if wrong
dns-prefetchResolves DNS for an originNegligible; a wasted lookup
preconnectDNS + TCP + TLS to an originHolds an idle connection for ~10s; wasteful beyond 2–4 origins
preloadFetches a specific file at high prioritySteals bandwidth and priority from the real critical path
modulepreloadFetches and parses an ES module and its graphSame as preload plus compile cost
prefetchFetches for a future navigation at lowest priorityWasted bytes if the navigation never happens

The distinction that matters most in practice is preconnect versus preload. preconnect prepares a pipe without knowing what will travel through it — ideal when you know the origin (a font host, an image CDN, an API) but not yet the exact URL. preload names an exact file, which means you must be certain the page will use it. Choosing between them is the subject of preload vs preconnect: when to use each.

Prerequisites

  • A CDN or origin server that can set response headers per route. Header-based hints (Link:) are processed earlier than markup-based ones and are the only form that works with Early Hints.
  • HTTP/2 or HTTP/3 end to end. On HTTP/1.1 the connection limit makes aggressive hinting counterproductive.
  • The Network panel with the Priority column enabled — hints change priority, and priority is what decides which request wins when bandwidth is scarce.
  • A known LCP element per route. Every decision below is made relative to it: a hint that delays the LCP resource is a regression regardless of what it accelerates.

1. Establish the Critical Path Before Adding Any Hint

Hints reorder a queue. If you do not know the current order, you cannot know whether your change improved it.

bash
# Requests in start order with priority, for the routes that matter.
npx lighthouse https://example.com --output=json --quiet \
  | jq -r '.audits["network-requests"].details.items[]
           | "\(.startTime|floor)ms  \(.priority)  \(.resourceType)  \(.url|split("?")[0])"' \
  | head -25
# trade-off: Lighthouse's simulated throttling distorts absolute timings. Use it
# for ORDER and PRIORITY; take absolute numbers from a real device trace.

Look for the signature problem: the LCP image at Low priority, starting after two stylesheets and a font. That is a discovery-and-priority problem, and it is exactly what hints exist to fix — though for images specifically, fetchpriority is often the better tool, as covered in using fetchpriority to prioritize the LCP image.

2. Preconnect the Two Origins That Matter

Preconnect is cheap per origin and expensive in aggregate. Each hint holds an open connection for roughly ten seconds whether or not it is used, consuming client resources and sometimes a server connection slot.

html
<!-- In <head>, as early as possible: the origins the critical path needs. -->
<link rel="preconnect" href="https://images.example-cdn.com" crossorigin>
<link rel="preconnect" href="https://api.example.com">
<!-- Cheap fallback for origins that are likely but not certain. -->
<link rel="dns-prefetch" href="https://analytics.example.net">
<!-- trade-off: two or three preconnects is the practical ceiling. Beyond that
     you are opening connections that compete with the ones already carrying
     your HTML and CSS — measure LCP before and after, per origin. -->

The crossorigin attribute must match how the resource will be fetched. Fonts and any CORS-mode fetch need it; a plain image from an image CDN does not. A mismatch means the warmed connection is never reused and you have paid the handshake twice.

3. Preload Only What Defines the Above-the-Fold Experience

The preload rule that survives contact with production: preload a resource only if it is (a) needed for the first viewport, (b) discovered late by the parser, and (c) not already high priority. All three conditions, not any one.

html
<!-- Late-discovered, first-viewport, currently low priority: a genuine case. -->
<link rel="preload" href="/fonts/inter-latin.woff2" as="font"
      type="font/woff2" crossorigin>
<link rel="preload" href="/css/critical.css" as="style">
<link rel="modulepreload" href="/js/route-home.js">
<!-- trade-off: `as` is not optional metadata — it sets the priority and the
     Accept header. A preload with the wrong `as` fetches a second copy at the
     wrong priority, which is strictly worse than not preloading at all. -->

An image already in the initial HTML does not meet condition (b): the preload scanner finds it during parsing anyway, so fetchpriority="high" on the <img> is the correct tool and a preload adds nothing. Fonts, on the other hand, are referenced from CSS and meet all three conditions, which is why they are the canonical preload example.

Chrome will warn in the console when a preloaded resource goes unused within a few seconds. Treat every such warning as a bug, because it means you spent critical-path bandwidth on nothing — the diagnostic walkthrough is in fixing unused preload warnings.

4. Move the Hints Into Response Headers

Markup hints are processed when the parser reaches them, which on a large document can be several kilobytes in. Header hints are processed the moment the response headers arrive — before a single byte of body is parsed.

nginx
# Same hints, delivered a full document-parse earlier.
location / {
  add_header Link '</fonts/inter-latin.woff2>; rel=preload; as=font; type="font/woff2"; crossorigin' always;
  add_header Link '<https://images.example-cdn.com>; rel=preconnect' always;
}
# trade-off: header hints apply to EVERY response from this location, including
# routes that do not use the font. Scope them per route, or you will preload a
# heading font on an API response and pay for it on every request.

Header hints are also the only form that CDNs can inject or rewrite at the edge, which matters when your origin is slow to render but your CDN knows the asset manifest for the route.

5. Send 103 Early Hints for Server Think Time

Early Hints is the logical endpoint of this progression. The server sends an informational 103 response carrying Link headers immediately, then continues generating the real response. The browser starts fetching the hinted resources during what would otherwise be dead time.

javascript
// Cloudflare Workers-style edge handler: hint first, render second.
export default {
  async fetch(request, env, ctx) {
    const hints = [
      '</css/critical.css>; rel=preload; as=style',
      '</fonts/inter-latin.woff2>; rel=preload; as=font; crossorigin',
      '<https://images.example-cdn.com>; rel=preconnect'
    ].join(', ');

    // The platform sends 103 with these headers while the origin still works.
    const early = new Response(null, { status: 103, headers: { Link: hints } });
    const real = await renderPage(request, env);
    real.headers.set('Link', hints);   // repeat for clients that ignored the 103
    return real;
  }
};
// trade-off: Early Hints only pays off when server think time is real — a TTFB
// above roughly 200ms. On a page served from edge cache in 20ms there is no dead
// window to fill, and the extra round trip of informational headers can cost
// more than it saves. Measure TTFB first.

The gain is proportional to how long your origin thinks. A personalised page with a 600ms TTFB can start its CSS and font downloads 500ms earlier — often the single largest LCP improvement available without touching the application. A statically cached page gains almost nothing. CDN-specific configuration and the caching interactions are covered in using 103 Early Hints behind a CDN.

Deconstructing Priority: What Hints Actually Change

A hint's effect is mediated entirely by the browser's priority system, so it helps to know what you are moving.

  • Highest / High — HTML, CSS in <head>, synchronous scripts before the first image, fonts that are actively blocking text, and any resource marked fetchpriority="high".
  • Medium — images in the initial viewport once discovered, defer and async scripts after the parser is finished.
  • Low — images outside the viewport, prefetch targets, and most third-party requests.

preload promotes a resource into the band implied by its as value. That is why the wrong as is worse than no hint: as="fetch" on a stylesheet requests it at a lower band and under different credentials, producing a duplicate request that the CSS parser later ignores.

The corollary is that hints cannot create bandwidth. On a constrained connection, promoting three resources demotes everything else by the same amount. If your LCP image and your hero font are both High, they now share the pipe, and one of them arrives later than it did before. Always re-check the LCP timestamp after adding a hint; a faster font that costs 120ms of LCP is a net loss.

Advanced Diagnostics and Failure Modes

The duplicated-request signature. Two requests for the same URL, one from the preload and one from the real use, means a mismatch in as, crossorigin, or type. Fonts are the usual victim because they are always fetched anonymously — a font preload without crossorigin guarantees a duplicate download.

Prefetch competing with the current page. prefetch is low priority, but on a slow connection low priority still consumes bytes. Prefetching the next route during the initial load can delay LCP on the current one. Gate prefetches behind an idle callback or a hover intent, and skip them entirely when navigator.connection.saveData is set.

Hints that outlive the resource they point at. Content-hashed filenames change on every build. A hint written into an nginx config or a CDN rule quickly points at a file that no longer exists, producing a 404 at high priority — the worst of both worlds. Generate header hints from the same build manifest that produces the filenames, never by hand.

Early Hints and caching interact. A cached 103 must be revalidated with the response it accompanied, or you will hint assets from a previous deploy. Ensure your CDN keys the informational response together with the main response, and confirm with a hard-refresh test after a deploy that changes the asset manifest.

Cache-partitioned connections. Preconnecting to an origin the page never uses is not just neutral — it consumes a connection slot and, on some mobile radios, keeps the radio in a high-power state. Audit preconnects whenever a vendor is removed; they are the most commonly orphaned tag in a <head>.

Validation and Budgeting

Hints are easy to add and easy to leave behind, so the budget should test for waste as well as for absence.

json
{
  "ci": {
    "assert": {
      "assertions": {
        "uses-rel-preconnect": ["warn", { "minScore": 1 }],
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "server-response-time": ["warn", { "maxNumericValue": 400 }],
        "render-blocking-resources": ["error", { "maxNumericValue": 300 }]
      }
    }
  }
}

Then add a check no standard audit performs: fail the build if the console emitted an unused-preload warning during the run. Lighthouse surfaces these in its console messages, and a simple assertion over that list catches the most common regression — a preload left in place after the asset it named was renamed or removed.

In the field, the signal to watch is the LCP resource-load-delay phase. That phase is exactly the discovery gap hints exist to close, so a successful hinting change shows up there first and most clearly, well before it moves the aggregate p75.

FAQ

Should I preload my LCP image?

Usually no — mark it fetchpriority="high" instead. An <img> in the initial HTML is already found by the preload scanner during parsing, so a preload adds a second discovery path without making the fetch any earlier, and it risks a duplicate request if the attributes do not match exactly. Preload genuinely helps when the LCP image is not discoverable from the HTML: a CSS background image, or an image whose URL is computed by JavaScript.

How many preconnect hints are too many?

Two or three is the practical ceiling for the critical path. Each preconnect holds an open connection for about ten seconds and competes with the connections already carrying your HTML and CSS, so the fourth and fifth hints usually cost more than they save. Use dns-prefetch for the remaining likely-but-not-certain origins — it resolves the name without opening a socket.

Do Early Hints help a page served from the CDN cache?

Barely. Early Hints fills server think time, and a page served from edge cache has almost none — the HTML arrives in tens of milliseconds, so there is no window in which to start other downloads. The technique pays off on personalised, uncacheable or slow-origin responses where TTFB is a few hundred milliseconds or more. Measure TTFB per route and enable it where the dead time is real.

Why does Chrome say my preloaded resource was not used?

Because the request the preload made and the request the page later made are not identical. The usual causes are a missing crossorigin on a font, a wrong or absent as value, a differing type, or a media query that excluded the resource on this viewport. Compare the two entries in the Network panel side by side — if any of the request attributes differ, the browser treats them as different resources and downloads both.