Debugging “preloaded but not used” Console Warnings
Symptom: Chrome prints The resource <URL> was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate 'as' value and it is preloaded intentionally — for a resource the page demonstrably does use, and the Network panel shows that URL twice.
Root Cause: An Unclaimed Entry in the Preload Map
The warning is emitted by the document’s fetcher, not by anything on the wire. When Blink parses a <link rel="preload">, it builds a request, starts it, and registers the resulting resource in the ResourceFetcher’s preload map. That map is not keyed by URL alone. The entry carries the request’s destination (derived from as), its request mode, its credentials mode, and its integrity and referrer-policy metadata alongside the resolved URL. When a real consumer later creates a request — the HTML parser, the CSS engine resolving an @font-face, a fetch() call, the module map — the fetcher looks the URL up in the preload map and tests whether the pending entry is reusable for this new request. A hit marks the entry claimed and hands the consumer the in-flight response. A miss leaves the entry sitting in the map and sends the consumer to the network on its own.
Roughly three seconds after the window load event, a timer walks whatever is still unclaimed in that map and prints one console message per entry. This is why the message is so often misread. It describes the state of a map, not a fact about the network, and two very different situations produce byte-identical text. In the first, nothing on the page ever asked for that URL: one wasted transfer, one wasted high-priority slot. In the second — much more common and much more expensive — a consumer did ask for it, but with a key the entry could not satisfy, so the page paid for the bytes twice and still waited the full round trip it was trying to eliminate. That case is strictly worse than having no hint at all, and the console text points you at as when the culprit is usually the CORS pair.
The mapping is fixed by spec and worth memorising. A <link> with no crossorigin content attribute produces a request in mode no-cors with credentials mode include. A bare crossorigin (or crossorigin="anonymous") produces mode cors with credentials same-origin. crossorigin="use-credentials" produces mode cors with credentials include. Consumers have their own fixed pairs that you do not get to choose, so the hint must be written to meet them.
Written out as a lookup table, the required crossorigin value is entirely determined by the consumer:
| Consumer | Request mode | Credentials mode | crossorigin the hint needs |
|---|---|---|---|
CSS @font-face |
cors |
same-origin |
bare crossorigin |
fetch(url) with defaults |
cors |
same-origin |
bare crossorigin |
| ES module graph | cors |
same-origin |
use rel="modulepreload" |
Classic <script src> |
no-cors |
include |
none |
<img src> without crossorigin |
no-cors |
include |
none |
<img crossorigin> |
cors |
same-origin |
bare crossorigin |
fetch(url, { credentials: 'include' }) |
cors |
include |
crossorigin="use-credentials" |
Minimal Reproduction
Four hints, each of which looks correct in review and each of which earns a warning for a different reason. Serve this from any origin and reload with the cache disabled.
<!doctype html>
<meta charset="utf-8">
<title>Four ways to earn the unused-preload warning</title>
<!-- BROKEN 1 — font. No crossorigin means mode "no-cors" with credentials
"include". A CSS font fetch is unconditionally mode "cors" with
credentials "same-origin", so this entry can never satisfy it: the
scheduler runs two transfers and the swap still waits for the second. -->
<link rel="preload" href="/f/inter-var.woff2" as="font" type="font/woff2">
<!-- BROKEN 2 — JSON read by fetch(). as="fetch" gives the empty destination
that fetch() uses, but fetch() defaults to mode "cors"; without the bare
crossorigin this entry stays "no-cors" and the bootstrap call re-fetches
on the critical path it was supposed to shorten. -->
<link rel="preload" href="/api/bootstrap.json" as="fetch">
<!-- BROKEN 3 — module. The module map fetches in "cors" mode; preload
as="script" defaults to "no-cors", so the entry is stranded and the
graph is re-requested after parse instead of during it. -->
<link rel="preload" href="/js/app.mjs" as="script">
<!-- BROKEN 4 — responsive image. The hint names one fixed candidate, but the
img element re-runs candidate selection and picks the 1600w file on a 2x
display, so the preloaded URL is one nothing ever asks for. -->
<link rel="preload" href="/img/hero-800.avif" as="image">
<link rel="stylesheet" href="/css/app.css">
<script type="module" src="/js/app.mjs"></script>
<img src="/img/hero-800.avif"
srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
sizes="100vw" width="1600" height="900" alt="Hero">
The font case is the one worth watching in the waterfall, because the duplicate transfer lands squarely inside the window where the first paint is waiting on the typeface.
Counting rows by eye stops scaling past a handful of hints, so pair the console with a probe that reports the request count for every preloaded URL. Run it after Chrome’s own timer so the two lists describe the same moment:
// Chrome reports unused preloads ~3 s after load; wait past that so this
// table and the console warnings describe the same set of map entries.
addEventListener('load', () => setTimeout(() => {
const counts = new Map();
for (const e of performance.getEntriesByType('resource')) {
// A CLAIMED preload yields exactly one entry — the hint's own fetch, later
// handed to the consumer. A mismatch yields two entries with the same name,
// which is the signal that separates a dead hint from a broken key.
counts.set(e.name, (counts.get(e.name) || 0) + 1);
}
const rows = [...document.querySelectorAll('link[rel="preload"]')].map((l) => ({
url: new URL(l.href, location.href).pathname,
as: l.as || '(unset)',
crossorigin: l.crossOrigin ?? '(absent)',
requests: counts.get(new URL(l.href, location.href).href) || 0,
}));
console.table(rows);
}, 4000));
A row showing requests: 2 next to a console warning for the same URL is a key mismatch. A row showing requests: 1 next to a warning is a dead hint. A row showing requests: 1 with no warning is a hint doing its job.
Deterministic Fix Protocol
Classify before you edit. The console text is identical across all three causes, so the Network panel — not the message — decides which branch you are on.
Work the steps in order and re-check after each one; stacking two changes loses the attribution.
-
[ ] Step 1 — Capture a clean warning list. DevTools → Console, filter on
was preloaded using link preload, withDisable cacheticked and throttling atFast 4G. Record every URL named. A warm cache can satisfy the consumer from disk and hide the duplicate row that identifies the cause. -
[ ] Step 2 — Count the rows per URL. In the Network panel, filter on each warned filename. Two rows for one URL is a key mismatch (steps 3–5); one row is a dead or late hint (step 6). If the two rows differ in the
Prioritycolumn — typicallyHighthenHighestfor a font — you are looking at a preload and its unmatched consumer, not at two unrelated fetches. -
[ ] Step 3 — Align the CORS pair. Use the lookup table above. Fonts and default
fetch()calls both need a barecrossorigin, same-origin or not. Do not reach foruse-credentialsunless the consumer genuinely setscredentials: 'include'; it flips the credentials mode and breaks the match from the other side. -
[ ] Step 4 — Align the destination. Verify that
asproduces the destination the consumer uses, and that it is present at all — a missingasyields the empty destination and a Lowest-priority fetch that will never match a typed consumer. Replace everyrel="preload" as="script"that points at an ES module withrel="modulepreload", which carries the module graph’s own fetch parameters and additionally primes the static imports; see modulepreload and ES module loading for the graph-level behaviour. -
[ ] Step 5 — Align the URL byte for byte. Compare the resolved preload URL against the consumed URL, including query string, casing and any cache-busting hash. For responsive images, mirror the element’s candidate list on the hint with
imagesrcsetandimagesizesinstead of naming one file, so the hint resolves to whatever the element selects at the current DPR and viewport.<!-- The hint must run the SAME candidate-selection algorithm as the element, otherwise the preloaded URL is a URL the img never requests. Omitting href is correct here: imagesrcset supplies the candidates. --> <link rel="preload" as="image" fetchpriority="high" imagesrcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w" imagesizes="100vw"> <!-- crossorigin is deliberately absent on BOTH: an img without the attribute fetches no-cors/include, which is exactly what a bare hint produces. --> <img src="/img/hero-800.avif" srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w" sizes="100vw" width="1600" height="900" fetchpriority="high" alt="Hero"> -
[ ] Step 6 — Delete or retarget the single-row hints. If the route never uses the asset, remove the hint; every preload occupies a scheduling slot that the render-critical set is competing for. If the asset is real but only needed after an interaction, it was never a preload candidate —
preloaddeclares a mandatory fetch for the current navigation. Move it torel="prefetch", which fetches at idle priority and is never reported as unused. -
[ ] Step 7 — Re-measure the full set. Reload with the cache disabled, confirm one row per preloaded URL, and confirm the console is empty. Then read the waterfall once more: the correct outcome is that the consumer’s start time moved earlier, not merely that a row disappeared. Reading the phase bars rather than totals is covered in decoding the DevTools network waterfall.
-
[ ] Step 8 — Assert it in CI. The warning is a console message, so it is trivially catchable in a headless run and trivially reintroduced by the next template change.
// Playwright. The warning is emitted ~3 s after load, so the wait is not // padding — ending the run at networkidle would pass a broken page. const unused = []; page.on('console', (m) => { if (m.text().includes('was preloaded using link preload')) unused.push(m.text()); }); await page.goto(url, { waitUntil: 'load' }); await page.waitForTimeout(4000); if (unused.length) throw new Error(`Unused preloads:\n${unused.join('\n')}`);
Before / After Metrics
Measured on the reproduction template above: product page, Fast 4G emulation, cold cache, median of nine runs. “After” applies steps 3 to 6 only — no asset was resized, recompressed or removed, and no new hint was added.
| Measurement | Where to read it | Before | After | Delta |
|---|---|---|---|---|
| Unused-preload warnings | Console, filtered | 4 | 0 | −4 |
Rows for /f/inter-var.woff2 |
Network panel | 2 | 1 | −1 |
| Bytes for that font | Network, Size column |
168 KB | 84 KB | −84 KB |
document.fonts.ready |
Performance panel marker | 1 240 ms | 610 ms | −630 ms |
Rows for /js/app.mjs |
Network panel | 2 | 1 | −1 |
| Hero image rows | Network panel | 2 (800w + 1600w) | 1 (1600w) | −1 |
Requests at High or above |
Network, Priority column |
9 | 5 | −4 |
| Total transfer | Network summary bar | 1.62 MB | 1.31 MB | −310 KB |
| Largest Contentful Paint | Performance panel | 2 380 ms | 1 690 ms | −690 ms |
The row that matters most is document.fonts.ready. Nothing about the font changed — same file, same 84 KB, same server — yet it becomes usable 630 ms earlier, because the second transfer was the entire delay. That is the shape of every mismatch fix: the saving is a round trip you were paying twice for, not a byte you removed. If FOUT is what brought you here, the follow-on tuning lives in font loading optimization.
FAQ
The warning fires but the Network panel shows only one request. Is that harmless?
It is cheaper than the two-request case, not harmless. One row means the hint fetched the bytes and nothing ever claimed them, so you paid the full transfer plus a High-priority scheduling slot during exactly the window when the stylesheet and the LCP image were competing for bandwidth. On a constrained connection that displaces a render-critical resource by roughly the duration of the wasted transfer. Delete the hint, or move it to the route that actually consumes the asset.
The resource really is used, just after a user interaction. Can I silence the warning?
There is no API to suppress it, and no attribute that opts out. Chrome starts the timer at the window load event and reports whatever is still unclaimed about three seconds later, so an asset consumed on click will always be named. The warning is right in spirit: preload declares a mandatory fetch for the current navigation at the destination’s own priority, which is not what an interaction-gated asset wants. rel="prefetch" fetches at idle priority, stores the response for the next navigation, and is never reported as unused.
Why does a same-origin font preload need crossorigin when nothing is cross-origin?
Because the request mode is a property of the consumer, not of the origin. CSS font fetches are unconditionally mode cors with credentials mode same-origin, even for a file served from the same host, so a hint with no crossorigin attribute differs from the real request in two of the four map keys and can never be matched. Adding the bare attribute puts the entry into cors plus same-origin and it is claimed on the first attempt. The same reasoning explains why as="fetch" needs it: fetch() also defaults to cors with same-origin.
Related
- Mastering Link Rel Preload & Prefetch — parent topic: attribute semantics, priority mapping and the full verification workflow
- Preload vs Prefetch vs modulepreload: a Decision Matrix — sibling guide: choosing the directive whose defaults already match your consumer