Preload Is a Zero-Sum Game

An audit flags a slow LCP. The recommended fix is to preload the hero image, so you do. Then, since you’re in there, you preload the two web fonts, the stylesheet, the analytics script, and the SVG logo. Six hints, all defensible.
The next run comes back worse.
This surprises people because preload is filed mentally under make things faster, as though it were a compression setting. It isn’t. A preload does not widen the pipe, shorten the round trip, or conjure a spare connection. It changes where a resource sits in a queue with a fixed number of seats. Promote six things and you have promoted nothing — except that now the browser is spending its earliest, most contended bytes on a logo.
Static sites make this failure mode worse, not better. Your HTML is already sitting on a CDN edge, so it arrives fast and the browser’s preload scanner sees the entire markup almost immediately. There is very little discovery latency left to buy back. Most of the preloads people add to a static site are paying down a debt the architecture already cleared.
What a preload actually does
Two mechanisms are easy to conflate.
The first is the preload scanner: a lightweight parser that races ahead of the main HTML parser looking for subresources. When it finds <img src>, <script src>, or <link rel="stylesheet"> in your initial markup, it starts the fetch immediately — even while the main parser is blocked on a synchronous script. This is free, automatic, and has been in every major browser for years.
The second is <link rel="preload">: an explicit instruction to fetch a specific URL now, at the priority implied by its as value, and hold it in the memory cache until something asks for it.
That distinction is the whole article. If a resource appears as a plain attribute in the HTML your server sent, the scanner already found it. Preloading it does not make the browser aware of it sooner — it makes the browser fetch it ahead of its peers. And its peers include the thing you actually care about.
<!-- Redundant. The scanner found this in the same pass. -->
<link rel="preload" as="style" href="/styles/site.css" />
<link rel="stylesheet" href="/styles/site.css" />
Render-blocking stylesheets in <head> are already among the highest-priority fetches a browser makes. There is no seat above them to promote into.
The zero-sum part
Bandwidth over the first few hundred milliseconds is fixed. HTTP/2 and HTTP/3 multiplex, which removes the six-connections-per-origin ceiling but not the ceiling that matters: the pipe is only so wide, and the browser has to decide whose bytes go first.
That decision is a priority ordering. Broadly, the browser wants render-blocking CSS first, then blocking scripts, then fonts and in-viewport images, then everything below the fold, then the genuinely speculative. It gets this right most of the time, because it can see the document structure and you cannot see the network.
A preload overrides that judgment. Giving a font as="font" treatment moves it up the queue. On a page whose LCP is a large hero image, those font bytes and those image bytes are now competing over the same congested window — and you have explicitly told the browser to resolve the tie in favour of the font. The image lands later. LCP regresses. Every hint you added was individually reasonable.
A preload is not a request for more. It is a request to go first. Someone else goes second.
The three cases that earn it
Preload is worth its cost when the browser genuinely cannot discover the resource early. There are about three of those.
1. Resources referenced from inside CSS. A font declared in @font-face, or a background image in a stylesheet rule, is invisible until the CSS has downloaded, parsed, and matched a rule against the DOM. That’s a serial chain: HTML, then CSS, then font. Preloading the font collapses it.
<link
rel="preload"
as="font"
type="font/woff2"
href="/fonts/inter-var.woff2"
crossorigin
/>
The crossorigin attribute is not optional, and omitting it is the most common way this hint gets silently wasted. Font fetches are always made in CORS mode. A preload without crossorigin creates a cache entry the real font request will not match, so the file downloads twice and the hint makes things strictly worse. Same-origin or not, fonts always get the attribute.
2. Late-discovered modules. A dynamic import inside a bundle is only found after that bundle downloads and executes. If you already know a chunk is needed on this page, modulepreload skips the round trip and resolves the module graph in parallel.
<link rel="modulepreload" href="/assets/chart-panel.BqR7.js" />
3. A responsive LCP image. The one case where preloading an image is defensible — and you need imagesrcset and imagesizes to match what the <img> will actually select, or you preload the wrong variant and download two.
<link
rel="preload"
as="image"
href="/media/hero-1024.webp"
imagesrcset="/media/hero-640.webp 640w, /media/hero-1024.webp 1024w"
imagesizes="(max-width: 700px) 100vw, 1024px"
/>
Even here, check whether you need it. If the <img> is in the server-rendered HTML — which on a static site it almost always is — the scanner found it in the first packet. The remaining win is priority, not discovery, and there is a cheaper way to buy priority.
fetchpriority is usually the better tool
fetchpriority re-ranks a fetch the parser already knows about. It adds no request, carries no duplicate-fetch risk, and leaves no stale URL to maintain. It’s a hint browsers may ignore, which makes it safe to be wrong about.
<img src="/media/hero.webp" alt="" width="1200" height="630" fetchpriority="high" />
<img
src="/media/screenshot-3.webp"
alt=""
width="800"
height="500"
loading="lazy"
fetchpriority="low"
/>
Browsers start images at a low priority and promote the in-viewport ones once layout tells them what’s visible. fetchpriority="high" on the LCP element skips that wait. Just as usefully, fetchpriority="low" on below-the-fold images, third-party embeds, and non-critical scripts demotes competitors — and because the game is zero-sum, demoting the right things is often more effective than promoting the right thing.
Rule of thumb: exactly one fetchpriority="high" per page. If you have two, you have none.
preconnect: capped at two, ideally zero
preconnect opens DNS, TCP, and TLS to an origin before anything requests it. Ahead of a cross-origin critical resource that can be worth a few hundred milliseconds, and it’s the hint most often under-used.
It’s also the one with a hard cap. Each warm socket costs the client and the server real work, and a connection nobody uses is pure waste that competed with real traffic while it was being established. Two preconnects, maximum, and only to origins on the critical path. For anything speculative, dns-prefetch costs a DNS lookup and nothing else.
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<link rel="dns-prefetch" href="https://maps.example.com" />
The better move on a static site is to delete the need. Every preconnect in your <head> is documentation of a third-party origin you decided to keep. Self-host the font, inline the icon, drop the embed — now it’s same-origin on a connection that is already warm, and the hint disappears along with the DNS lookup, the handshake, and the privacy footnote.
While we’re here: HTTP 103 Early Hints exists to send hints before the server has finished thinking about the response. If your HTML is a file on a CDN edge, the server isn’t thinking. There’s no gap to fill.
prefetch plays a different game entirely
Everything above competes over the current page’s load. prefetch doesn’t — it’s an idle-time, lowest-priority request for something the next navigation will need. Different budget, different rules.
This is the hint static sites should be spending on. Your pages are small, fully cacheable, and carry no per-request server cost, which makes speculative fetching cheap in exactly the way it isn’t for a database-backed app.
<link rel="prefetch" href="/posts/the-next-article/" as="document" />
The disciplined version prefetches on intent — hover, or viewport intersection — rather than blanket-fetching every link in the nav. Astro and Next.js both ship this behaviour for internal links, and it’s worth turning on before you touch a single preload. Chromium’s Speculation Rules go further with prerender, but that runs the whole page including its scripts: treat it as expensive and reserve it for a genuinely dominant next click.
Auditing what you already have
Load the page cold with the cache disabled and sort the Network panel by the Priority column. You’re looking for two things: anything at High that isn’t render-critical, and your LCP resource sitting below something that could have waited.
Then read the console. Browsers warn when a preloaded resource goes unused within a few seconds of load — a stale href after a bundler hash change, an as value that doesn’t match the eventual request, a missing crossorigin. That warning means you paid the full download cost for a file the page then fetched again or never used at all. It’s the most common preload bug and the easiest to fix, because the browser hands it to you.
On a static site, stale hints are worth catching mechanically. The hints live in a layout template; the files they point at are emitted by the build with content hashes. Nothing enforces that the two agree, so write the check yourself:
// scripts/check-preloads.mjs — every preload href must exist in dist/
import { existsSync, readFileSync, globSync } from 'node:fs'
const pattern = /<link[^>]+rel="(?:preload|modulepreload)"[^>]*>/g
const hrefOf = /href="([^"]+)"/
let dead = 0
for (const page of globSync('dist/**/*.html')) {
for (const tag of readFileSync(page, 'utf8').match(pattern) ?? []) {
const href = tag.match(hrefOf)?.[1]
if (!href || href.startsWith('http')) continue
if (!existsSync(`dist${href.split('?')[0]}`)) {
console.error(`dead preload in ${page}: ${href}`)
dead++
}
}
}
process.exit(dead ? 1 : 0)
Crude, but it turns “we broke a preload three deploys ago” into a red build. That’s the general principle: a resource hint is a hard-coded assertion about your build output, and assertions belong in CI.
The rules worth keeping
- Don’t preload anything the preload scanner can already see in your HTML.
- Preload only for late discovery: CSS-referenced fonts, dynamic modules, responsive LCP images.
- Fonts always get
crossorigin, or you download them twice. - One
fetchpriority="high"per page — and reach forfetchpriority="low"at least as often. - Two
preconnectorigins, maximum. Better: remove the third party and need zero. - Spend the speculation budget on
prefetchfor the next navigation, not on the current page. - Check the console for unused-preload warnings after every meaningful change.
The takeaway
Resource hints look like a list of optimisations you can accumulate. They’re closer to a seating chart. The browser already has a good default ordering, informed by the document structure it just parsed, and every hint you add is a claim that you know something it doesn’t.
Sometimes you do. The font buried three levels deep in a stylesheet is a real blind spot, and no amount of parser cleverness finds it early. But six hints on a static page is not six pieces of insider knowledge. It’s a page where you told the browser that everything is first, which is the same as telling it nothing.
Delete the hints you can’t justify. Then measure the one that’s left.

