You added <link rel="preload" as="script"> for a vendor’s ES module, the entry file arrives early as intended, and the page is no faster — because the module’s own imports are still discovered one round trip later, and the browser is warning that the preload went unused.
Triage: Confirm the Imports Are the Bottleneck
- Open DevTools → Network, throttle to Slow 4G, and disable the cache. Reload and sort by start time.
- Find the vendor entry module. Note whether requests to its dependencies begin at the same moment or measurably later.
- Check the Initiator column on those dependencies. An initiator naming the entry module confirms they were discovered by parsing it, not by your markup.
- Read the console for
The resource … was preloaded using link preload but not used within a few seconds. Withas="script"on a module, this is expected rather than mysterious.
Root Cause: preload Warms a File, modulepreload Warms a Graph
rel="preload" as="script" fetches exactly one URL and stores it in the preload cache as a classic script resource. For an ES module this is wrong in two ways. It does not fetch the module’s dependencies, because those are only known after the file is parsed — and it stores the response under the wrong resource type, so the module loader may not reuse it at all, producing the unused-preload warning and, in some configurations, a second fetch.
rel="modulepreload" is the module-aware equivalent. It fetches the URL as a module, parses it to discover its imports, and — at the browser’s discretion — fetches those too, populating the module map rather than a generic cache bucket. That is the behaviour the situation calls for: not “get this file early” but “get this subgraph ready”.
The distinction matters more for third-party modules than for your own, because you control your own bundling. A vendor’s module graph is whatever they published, it can change without notice, and you generally cannot flatten it — so warming it explicitly is the only lever available. This is the same reasoning behind the attribute-match rule for classic scripts, applied to a resource type with an extra level of indirection.
Why the Depth Matters More Than the Count
It is worth being precise about what the hint buys, because the intuition that “more parallel requests is better” is not quite the right model. Bandwidth on a constrained connection is shared, so fetching four modules simultaneously does not make each one four times faster — the total transfer time for a given number of bytes is broadly similar either way.
What changes is the number of sequential round trips. Each level of a module graph costs a full request-response cycle before the next level can even be discovered, and on a mobile connection that cycle is dominated by latency rather than by bandwidth: 150 ms of waiting to transfer 8 KB. A graph three levels deep therefore costs roughly 450 ms of pure waiting regardless of how small the files are, and no amount of bandwidth removes it.
That is why the technique pays off in inverse proportion to file size. A vendor shipping one large module gains almost nothing from modulepreload, because there is no second level to discover. A vendor shipping thirty small modules in a four-level graph gains a great deal, because almost all of their cost is latency rather than bytes. Check the shape before deciding whether the markup is worth adding — the Network panel’s waterfall, read as a staircase rather than as a list, shows it immediately.
The corollary is a diagnostic shortcut. If the vendor’s requests in the waterfall form a staircase, the graph is deep and this technique applies. If they form a block starting at the same moment, the graph is already flat and your time is better spent on the priority or the size of what is being fetched.
Resolution: Hint the Entry and Its Known Dependencies
<!-- Warm the entry module and let the browser follow its imports. -->
<link rel="modulepreload" href="https://cdn.vendor.example/sdk/3.4.1/index.js" crossorigin>
<!-- Where you know the graph and it is stable, name the heavy leaves explicitly.
The browser will not re-fetch anything already in the module map. -->
<link rel="modulepreload" href="https://cdn.vendor.example/sdk/3.4.1/core.js" crossorigin>
<!-- The consuming element. Attributes must match, exactly as for classic preload. -->
<script type="module" src="https://cdn.vendor.example/sdk/3.4.1/index.js" crossorigin></script>
Three details decide whether this works. crossorigin is required on every hint and on the script — module scripts are always fetched in CORS mode, so omitting it produces a mismatch and a duplicate fetch. Naming individual dependencies is optional and only worth doing for a graph you have measured, since the browser already follows imports from the entry. And the URLs must be byte-identical to those the module actually imports, including any version segment, or you have warmed a resource nobody asks for.
For a vendor whose graph changes between releases, hint the entry only. Enumerating dependencies pins you to a shape the vendor may change silently, and a stale list produces exactly the unused-preload warnings you were trying to remove.
Verification: One Round Trip, Not Two
Re-run the throttled trace. The dependency requests should now start alongside the entry rather than after it, and the console warning should be gone. In the Network panel, the entry module’s Initiator should read modulepreload rather than script, confirming the hint was the discovery mechanism.
Common Pitfalls
-
Using
as="module"on apreloadlink. There is no such value. The module-aware hint is a separate relation,modulepreload, with noasattribute at all. -
Omitting
crossorigin. Module scripts are always CORS-mode fetches. A hint without the attribute occupies a different cache entry and the module is fetched twice. -
Enumerating a graph you have not measured. Every hint you add is bandwidth spent before the parser reaches the script. Hint the entry, measure, and only then consider naming leaves.
-
Hinting a module that is already inline. If the vendor’s entry is inlined into your document rather than fetched, there is nothing to preload and the hint is pure waste. Check the actual markup rather than the integration guide.
-
Leaving the hints in place after removing the vendor. An orphaned
modulepreloadkeeps fetching a module nothing imports, which is bandwidth spent on a resource that is then discarded. Generate the hints from the same vendor registry that drives the script tags, and the two cannot diverge.
Frequently Asked Questions
Does modulepreload fetch the whole dependency tree automatically?
The specification permits it and browsers generally do, but it is a discretionary optimisation rather than a guarantee — the browser may fetch the entry, parse it, and follow imports, or it may stop at the entry under memory or bandwidth pressure. Treat automatic graph traversal as likely rather than certain.
That uncertainty is the argument for naming the one or two heaviest dependencies explicitly when a vendor’s graph is stable and you have measured it. It is also the argument against naming all of them: each hint is a commitment you have to keep updated, and the marginal benefit past the heaviest leaf is usually small.
Is this worth doing for a consent-gated vendor?
No — and for the same reason a classic preload is wrong there. The hint fetches the payload immediately, which is precisely what the consent gate exists to prevent, and the preload cache will evict it long before a consent decision arrives anyway.
Use preconnect to the vendor origin instead. It transfers no payload, so it raises no consent question, and it removes the handshake from the post-grant path — which is where the latency that matters actually lives for a gated vendor.
What about browsers that do not support modulepreload?
They ignore the link element entirely, which is the correct degradation: the module still loads through the <script type="module"> element, just without the warm start. There is no fallback to write and no feature detection to perform, because an unrecognised rel value is inert rather than erroneous.
What you should not do is ship both hints for the same URL as a compatibility measure. A preload and a modulepreload for one module produce two cache entries in browsers that understand both, which is the duplicate fetch you were trying to avoid — and the classic-script entry will not be used by the module loader anyway.
Related
- Implementing Preload and Prefetch for Third-Party Scripts
- fetchpriority vs preload vs preconnect: A Decision Matrix
- Optimizing the Network Waterfall for External Assets
Up: Implementing Preload and Prefetch for Third-Party Scripts