skip to content

For an ES module entry point, what does <link rel="modulepreload" href="/app.js"> do that <link rel="preload" as="script" href="/app.js"> does not?

level: middleimportance: nice to knowfreq 26%

answer

  1. bytes versus module map
  2. imports discovered one level at a time
  3. the hint reads the file
  4. CORS mode by default
  5. still nothing is executed

basics

~20 s

modulepreload fetches the module, parses it and puts it in the module map ready to evaluate, and browsers may follow its static imports to fetch the dependency graph too. A plain script preload only stores raw bytes and never looks at the imports.

solid answer

~50 s

`preload` with `as="script"` is byte-level: it downloads the file into cache and stops there. `modulepreload` is module-aware. It fetches the resource, parses it as an ES module and places it in the module map so it is ready to evaluate the moment something imports it, and browsers are permitted to walk the module's static `import` statements and fetch those dependencies too — which is the real prize, because a deep import graph otherwise unfolds one network round trip per level. It also gets the fetch mode right for free: module scripts are fetched in CORS mode, so a same-origin `modulepreload` matches the real request, whereas `preload as="script"` on a cross-origin module without `crossorigin` mismatches and downloads the file twice. What it does not do is execute anything — evaluation still waits for an actual import.

code

html · 5 lines
html
<script type="module" src="/app.js"></script>

<!-- parsed into the module map, ready to evaluate on import -->
<link rel="modulepreload" href="/lib/router.js">
<link rel="modulepreload" href="/lib/store.js">

go deeper

for a junior

Know that modulepreload is the module-aware version of preload: it prepares an ES module for the page, and it downloads rather than runs it.

for a middle

Explain the module map and the import waterfall: the hint parses the module and can fetch its static imports, so a deep dependency graph does not unfold one round trip per level.

for a senior

Show judgment about scope and origin. Limit hints to the graph the current entry actually needs, and generate them from the build manifest so hashed filenames cannot leave stale, silently wasted fetches.

for a principal

Own the tradeoff between flattening the module graph with hints and reducing the graph itself. Decide when the answer is fewer, larger chunks rather than more hints competing for the same bandwidth.

## The waterfall problem modulepreload exists to solve An ES module graph is discovered by *reading*. The browser fetches `/app.js`, parses it, sees `import { x } from './a.js'`, fetches `a.js`, parses it, sees its imports, and so on. Each level of the graph is one more round trip that could not have started earlier, because the browser did not know the URL existed. On a deep dependency tree over a high-latency connection, that serialisation dominates the load, and it is invisible in a bundle report because the bytes are small — it is the *shape* that costs. `<link rel="modulepreload">` exists to flatten that waterfall. ## What each hint actually stores `<link rel="preload" as="script" href="/app.js">` performs a fetch and parks the raw response. The browser does not interpret it. When a `<script type="module" src="/app.js">` later asks for it, the bytes come from cache — a real saving, but only for that one file. The imports inside it are still discovered by parsing after the fact, one level at a time. `<link rel="modulepreload" href="/app.js">` is defined in terms of the module system: ```html <script type="module" src="/app.js"></script> <link rel="modulepreload" href="/lib/router.js"> <link rel="modulepreload" href="/lib/store.js"> ``` It fetches the resource, parses it as a module, and inserts it into the **module map** — the per-document registry keyed by URL that guarantees each module is fetched and evaluated at most once. By the time the entry script runs, those modules are already resolved rather than merely cached. The specification also permits the browser to fetch the module's **static import dependencies**, and browsers do, so a single hint on a well-chosen entry point can start the whole subtree at once. ## Fetch mode comes out right Module scripts are always fetched in CORS mode. `modulepreload` inherits that, so a same-origin hint matches the real request with no extra attributes; a cross-origin module needs `crossorigin` on the hint just as it does on the script. With `preload as="script"` you have to reproduce that by hand, and forgetting is the classic bug: the plain preload requests the file in a mode the module request cannot reuse, so the browser fetches it twice and logs a warning that the preload went unused. This is the same request-matching rule that governs every preload, but here the correct answer is usually "use the module-aware hint instead". ## What it still does not do Evaluation. `modulepreload` prepares a module; it does not run it. Top-level code executes when something actually imports the module or when the entry `<script type="module">` evaluates. The hint is also useless without a consumer — a modulepreload for a module nothing imports is wasted bandwidth, exactly like any other unused hint. It also only helps with **statically analysable** imports. A dynamic `import()` whose specifier is built at runtime cannot be discovered by anybody in advance; if such a chunk is on the critical path, it needs its own explicit hint. That is the usual reason a hand-written `modulepreload` appears next to build-generated ones. ## Where these hints come from in practice You rarely hand-write them for a real application. Module URLs are hashed and change per build, so the hints have to be generated from the build manifest alongside the entry script — the same tooling that emits the `<script type="module">` tag emits the `modulepreload` lines for the chunks that entry needs. Hand-maintained hints go stale on the first rename and become invisible waste. ## Deciding how many to emit The usual failure is a template that emits a `modulepreload` for every chunk in the build. Now the browser starts dozens of high-priority requests, all sharing the connection with the CSS and the LCP image, including chunks for routes this page will never load. The right scope is the modules the **current** entry point needs to become interactive — the graph that would otherwise be discovered level by level — and nothing beyond it. As with every hint in this family, the value is in moving discovery earlier for things you were going to fetch anyway; the moment it starts fetching things you were not, it is a regression.

  • Does a same-origin modulepreload need a crossorigin attribute?
    No. Module scripts are fetched in CORS mode by definition, and a same-origin request satisfies that, so the hint matches the real request as written. A module served from another origin does need `crossorigin` on the hint, matching what the importing script uses — otherwise the two requests differ and the file is fetched twice.
  • Does modulepreload evaluate the module's top-level code?
    No. It fetches, parses and registers the module in the document's module map, and stops. Top-level statements run only when something imports it, or when the entry module script is evaluated. That separation is deliberate: it lets you prepare a module's cost without triggering its side effects.
  • Can it help a chunk loaded by a dynamic import()?
    Only if you name the URL explicitly. A dynamic `import()` with a runtime-computed specifier is undiscoverable ahead of time, so nothing can prefetch its graph automatically. If such a chunk sits on the critical path — a route the page always lands on — an explicit hint generated from the build manifest is the way to start it early.

saying these in an interview costs you the question

  • Thinks modulepreload executes the module
  • Uses preload as=script for cross-origin modules
  • Emits a modulepreload for every chunk in the build
  • Believes it discovers dynamic import() specifiers
  • Hand-writes hints for hashed build output

context