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.

Field-by-field comparison of a font preload entry and the CSS font-face request, showing two of the four keys mismatching so the entry is never claimed Two columns of four rows each are compared field by field. The preload map entry reads destination font, mode no-cors, credentials include, and the URL slash f slash inter-var dot woff2. The consumer request from CSS at-font-face reads destination font, mode cors, credentials same-origin, and the same URL. The destination and URL rows match; the mode and credentials rows mismatch. Two outcome cards below state that the entry stays unclaimed and that the console warning is printed about three seconds after the load event. The preload map keys on four fields — one mismatch and the entry is never claimed Same page, same URL: a font preloaded without crossorigin cannot satisfy the @font-face fetch Preload map entry Match test Consumer request destination: font match destination: font mode: no-cors mismatch mode: cors credentials: include mismatch credentials: same-origin URL: /f/inter-var.woff2 match URL: /f/inter-var.woff2 Entry stays unclaimed the consumer opens a second request Timer fires ~3 s after load console: preloaded but not used

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.

Waterfall showing the same font file transferred twice, once by an unclaimed preload and once by the CSS font-face request A four-row waterfall on a zero to twelve hundred millisecond axis. The document transfers first at one hundred and sixty milliseconds. The preloaded font transfers from fifty-five to four hundred and thirty milliseconds in no-cors mode. The stylesheet transfers from one hundred and seventy to five hundred and twenty milliseconds. The same font file is then transferred again from five hundred and forty-five to nine hundred and fifteen milliseconds in cors mode. A dashed marker shows the load event at one thousand and eighty milliseconds. One font file, two transfers: the preload entry is never claimed Fast 4G throttle, cold cache, /f/inter-var.woff2 at 84 KB, preload written without crossorigin 0 200 400 600 800 1000 1200 ms /product/42 inter-var.woff2 (preload) app.css inter-var.woff2 (@font-face) 160 ms no-cors fetch 375 ms 350 ms cors refetch 370 ms load event, 1080 ms 84 KB crosses the wire twice, and the text swap still waits for the second transfer to finish. document stylesheet the same font, transferred twice

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.

Decision tree that classifies an unused-preload warning by the number of network requests for the warned URL From an unused-preload warning, the tree branches on how many requests the Network panel shows for that URL. One request means nothing claimed the bytes, which splits into a dead hint to delete and an asset consumed after the three second timer that should become a prefetch. Two requests mean the consumer used a different key, and the fix is to align the as value, the request mode, the credentials mode or the URL. Branch on the Network panel, not on the console text Unused-preload warning count the rows for that URL Nothing claimed the bytes no consumer requested the URL Fetched, then fetched again the consumer used another key Dead hint asset not on this route delete the link element Consumed too late used after the 3 s timer switch to rel=prefetch Key mismatch as, mode, credentials or URL align the hint to the consumer one request two requests never used used late key mismatch

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, with Disable cache ticked and throttling at Fast 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 Priority column — typically High then Highest for 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 bare crossorigin, same-origin or not. Do not reach for use-credentials unless the consumer genuinely sets credentials: 'include'; it flips the credentials mode and breaks the match from the other side.

  • [ ] Step 4 — Align the destination. Verify that as produces the destination the consumer uses, and that it is present at all — a missing as yields the empty destination and a Lowest-priority fetch that will never match a typed consumer. Replace every rel="preload" as="script" that points at an ES module with rel="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 imagesrcset and imagesizes instead 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 — preload declares a mandatory fetch for the current navigation. Move it to rel="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.