Module loading in browsers
Learn how browsers resolve, fetch, cache, preload, and execute JavaScript modules with import maps, workers, and worklets.
- 01Trace module identityExplain why matching module URLs reuse one module instance while URL variants do not.
- 02Debug loading failuresCheck resolution, CORS, and JavaScript MIME types in the right order.
- 03Choose loading toolsUse import maps, modulepreload, module workers, and worklet modules deliberately.
A browser loading plan
Browser module loading turns an import specifier into a URL, fetches a module graph, links it, and evaluates it. The browser remembers loaded module results so the same module URL is not evaluated as a fresh module every time it is requested.
That sequence explains several practical questions. Why does import "cart" fail until an import map names it? Why can a server mistake such as text/html stop a JavaScript import? Why can a preload help a later import without running it early?
This follows Top-level await & async evaluation. That lesson explained when a ready module can evaluate; this one explains the browser work that gets modules ready.
URLs identify modules
A relative module specifier gets its meaning from the importing module URL. The browser does not guess from a project folder or package name. It uses URL resolution, the same everyday rule used by the URL constructor.
const url = new URL("./cart.js", "https://shop.example/js/main.js");
console.log(url.href);
const parentUrl = new URL("../cart.js", "https://shop.example/js/main.js");
console.log(parentUrl.href);Line 1 makes a URL for ./cart.js beside main.js, so line 2 prints https://shop.example/js/cart.js. Line 4 moves up one directory with ../, so line 5 prints https://shop.example/cart.js.
This is why moving a module can change what its relative imports mean. A bundler may rewrite URLs during a build, but the browser eventually receives URLs and resolves the graph from them.
The module map
The module map is browser bookkeeping for module scripts. The HTML specification keys it by request URL and module type. When another import asks for a completed matching entry, the browser uses that module instead of creating another module instance.
const modules = new Map();
function loadModule(url) {
if (!modules.has(url)) {
modules.set(url, { url, visits: 0 });
}
const module = modules.get(url);
module.visits += 1;
return module;
}
const first = loadModule("https://shop.example/js/cart.js");
const second = loadModule("https://shop.example/js/cart.js");
const versionTwo = loadModule("https://shop.example/js/cart.js?v=2");
console.log(first === second, modules.size, versionTwo.visits);Line 11 creates the first cart entry. Line 12 asks for the same URL, so line 14 prints true 2 1: the first two values are the same object, there are two URL entries after the versioned URL, and the versioned entry was visited once.
The request URL is the important part. A browser treats /js/cart.js and /js/cart.js?v=2 as two keys because their query strings differ. It can fetch and evaluate both. A path that normalizes to the same URL, such as /js/../js/cart.js, instead reaches the existing entry.
The map also lets imports meet at an entry that is still loading. The later importer waits for that work instead of starting a competing module graph. Once loading and linking finish, both importers see the same module namespace and the same exported state.
Do not confuse this bookkeeping with the HTTP cache. The HTTP cache may reuse response bytes under its own rules. The module map answers a different question: whether this document or worker already has a module result for this resolved URL and module type.
Replay instrumented teaching code. It models browser module-map identity; it is not an engine debugger.
script
function loadModule(url) { if (!modules.has(url)) { modules.set(url, { url, visits: 0 }); } const module = modules.get(url); module.visits += 1; return module;} const first = loadModule("https://shop.example/js/cart.js");const second = loadModule("https://shop.example/js/cart.js");const versionTwo = loadModule("https://shop.example/js/cart.js?v=2");console.log(first === second, modules.size, versionTwo.visits);Your phone downloads list keeps one item for one download. Opening that same item again does not make a second copy. A different link is a different item, even when the file name looks similar.
- In real life: One downloaded file URL
- In JavaScript: One module-map URL entry
- In real life: Open the same download again
- In JavaScript: Reuse the module instance
- In real life: A different download link
- In JavaScript: A query variant is a different key
Where the analogy stops: A browser also tracks module type and loading states; this analogy only explains repeated URL identity.
Fetching and MIME types
After resolution, a browser fetches module scripts using CORS. A cross-origin response must pass CORS checks. A JavaScript module response also needs a JavaScript MIME type, such as text/javascript; a server returning HTML or another wrong type makes the import fail.
<script type="module">
import { total } from "./cart.js";
console.log(total);
</script>
HTTP/1.1 200 OK
Content-Type: text/javascript
Access-Control-Allow-Origin: https://shop.exampleThe script block asks for ./cart.js. The HTTP fragment shows the server facts that matter: a JavaScript Content-Type and a CORS permission for a cross-origin request. These are server responses, so this fragment is intentionally not runnable in the editor.
A module request is stricter than a page casually receiving JavaScript-looking text. A common failure is a deployment fallback that returns an HTML page with status 200 for a missing .js file. The source may look like JavaScript in your editor, but the browser sees the response header and refuses to make a JavaScript module.
Cross-origin is a separate gate. If https://cdn.example hosts cart, that server must permit the calling origin through CORS. Adding an import map only changes the destination URL; it does not grant permission to read the response at that URL.
When a production import fails, start in the Network panel. Confirm the final response URL, status, Content-Type, and CORS headers before changing application code. That order separates a bad mapping, a missing file, a server MIME configuration, and a cross-origin policy problem.
Import maps in depth
An import map is JSON in a <script type="importmap"> element. It maps a name such as cart to a URL before a module is fetched. It must be processed before a document module that depends on its mapping.
const importMap = {
imports: { cart: "/js/cart.js", "lodash/": "/lib/lodash/" },
scopes: { "/admin/": { cart: "/admin/cart.js" } },
};
console.log(resolveSpecifier("cart", importMap, "https://shop.example/app.js"));
console.log(resolveSpecifier("lodash/map.js", importMap, "https://shop.example/app.js"));
console.log(resolveSpecifier("./cart.js", importMap, "https://shop.example/js/main.js"));Line 2 maps the exact bare name cart. Line 2 also maps the trailing-slash prefix lodash/, so lodash/map.js keeps map.js after the mapped directory. Line 3 gives modules under /admin/ a more local cart mapping.
Your phone contact list lets you tap a short name, then dials the full number. An import map lets cart stand for /js/cart.js.
- In real life: Tap a saved contact name
- In JavaScript: Use a bare module specifier
- In real life: Contact stores a full number
- In JavaScript: Import map stores a URL
- In real life: A work contact list
- In JavaScript: A scope overrides a mapping
Where the analogy stops: Import-map matching has URL normalization and longest-match rules that a contact list does not need.
For matching rules, exact keys win for the exact specifier. When several trailing-slash prefixes could match, the longest matching prefix wins. If no mapping matches, a relative or absolute URL can still resolve; an unmapped bare name throws an error.
An exact key has no automatic extension or subpath behavior. Mapping cart does not map cart/price.js. Add a second key ending in / when a package exposes many files, and make its destination end in / too. That slash tells the browser to append the unmatched remainder.
Scopes answer a different question: which module is doing the importing? A rule under /admin/ applies only when the referrer URL is inside that scope. If several scopes apply, the most specific one is checked first, then the browser can fall back to a wider scope and finally top-level imports.
Try the resolver
Edit only the cart mapping below. The output recomputes four common cases: an exact bare name, a prefix name, a relative URL, and an unmapped bare name.
const importMap = {
imports: { cart: "/js/cart.js", "lodash/": "/lib/lodash/" },
scopes: { "/admin/": { cart: "/admin/cart.js" } },
};
console.log(resolveSpecifier("cart", importMap, "https://shop.example/app.js"));
console.log(resolveSpecifier("lodash/map.js", importMap, "https://shop.example/app.js"));
console.log(resolveSpecifier("./cart.js", importMap, "https://shop.example/js/main.js"));cart -> https://shop.example/js/cart.jslodash/map.js -> https://shop.example/lib/lodash/map.js./cart.js -> https://shop.example/js/cart.jsmissing -> error: unmapped bare specifierThe exact cart mapping now resolves to https://shop.example/js/cart.js.
Replay the lesson resolver model. The full HTML algorithm has additional normalization and error cases; this model keeps the important choices visible.
script
imports: { cart: "/js/cart.js", "lodash/": "/lib/lodash/" }, scopes: { "/admin/": { cart: "/admin/cart.js" } },}; console.log(resolveSpecifier("cart", importMap, "https://shop.example/app.js"));console.log(resolveSpecifier("lodash/map.js", importMap, "https://shop.example/app.js"));console.log(resolveSpecifier("./cart.js", importMap, "https://shop.example/js/main.js"));The actual HTML algorithm also normalizes URL-like keys and consults the most specific matching scope before top-level imports. The small model deliberately keeps only the decisions you need to recognize while debugging an app.
Preload module work
modulepreload asks the browser to fetch, parse, and compile a module before a later import needs it. The prepared module is placed in the document module map, ready for a later module script to evaluate when its dependencies allow.
<link rel="modulepreload" href="/js/cart.js">
<script type="module" src="/js/main.js"></script>Line 1 starts preparing /js/cart.js early. Line 2 still starts main.js as a module. The preload does not execute cart early; it moves fetch and preparation earlier when you already know cart will be needed.
| Tool | What it does | What it does not do |
|---|---|---|
| Module map | Stores a module result by URL and module type. | Same key reuses one module instance. |
| HTTP cache | May reuse response bytes according to HTTP caching. | It is not the JavaScript module instance. |
| Import map | Turns a specifier into a URL before loading. | It does not download the module by itself. |
| modulepreload | Fetches, parses, and compiles a module early. | It prepares a module; it does not run it. |
Plain preload downloads a resource into cache. Module preload uses module loading rules and prepares the module result. Preload only important, likely modules: too many early requests can delay more important work.
Use it when a dependency is certainly needed and would otherwise be discovered late, such as a large module behind the entry module. It is less useful for a feature a visitor may never open. In that case an early request can steal bandwidth from the image, font, or entry script that the first screen needs.
Think of the before-and-after sequence. Without the hint, the browser finds cart.js only after it starts reading main.js. With the hint, the fetch can start while HTML is still being processed. Evaluation still happens only when the normal module graph reaches cart.
Module workers and worklets
A dedicated worker can be a module worker. Pass type: "module" and the worker file can use static import statements. Its graph uses module semantics instead of the classic worker's importScripts() pattern.
// main.js
const worker = new Worker("worker.js", { type: "module" });
// worker.js
import { total } from "./cart.js";
self.postMessage(total);Line 2 creates a module worker. In the worker file, line 5 imports cart.js, and line 6 sends the imported total back to the owner. A module worker's imports are fetched with CORS rules.
The worker gets its own global object and its own module-loading environment. A document import map is not copied into it. Give the worker URL explicitly, and make every worker dependency resolve in the worker's graph. For bundler-managed code, new URL("./price-worker.js", import.meta.url) gives the tool a stable asset relationship.
Worklets also load JavaScript modules, but they are small specialized environments such as audio and paint worklets. Their addModule() method returns a promise that resolves after the module has been added to that worklet.
const context = new AudioContext();
await context.audioWorklet.addModule("meter-processor.js");Line 1 creates an audio context. Line 2 awaits addModule, so later worklet setup can rely on the processor module being present. Browser availability and secure-context requirements still matter for real worklet APIs.
Keep the distinction practical: a worker is for code that communicates by messages and can run separately from the page. A worklet is an API-specific environment with tight rules. Both load modules, but neither is a hidden extension of the document's imports or global variables.
Practical loading choices
Use relative URLs when one module is genuinely beside another. Use an import map when the browser needs a stable public name such as cart, or when an application must redirect a group of specifiers to a controlled URL location.
Use modulepreload for a module on the important path that the browser would otherwise discover late. Verify it with the Network panel and real user measurements. A preload is a hint to make a dependency ready sooner, not a replacement for reducing unnecessary code.
- Map
cartto/js/cart.js. - Turn
./cart.jsinto an absolute URL. - Check a JavaScript MIME type.
- Fetch and compile cart early.
- Run main after its dependencies are ready.
- Start a module worker body.
Sort each action by the loading phase it describes.
For worker code, prefer a URL relative to import.meta.url when a build tool owns the worker file. This makes the worker path relative to the current module instead of the HTML page and helps bundlers track the asset.
Common misconceptions
- “A file is the module identity.” The browser uses a resolved URL and module type.
- “An import map downloads code.” It only changes resolution; loading follows afterward.
- “modulepreload runs code early.” It prepares a module for later evaluation.
- “CORS is only for fetch().” Module graph fetches use CORS too.
- “Import maps configure workers too.” Document import maps do not apply to modules loaded into workers or worklets.
| Term | Accurate meaning | Do not confuse it with |
|---|---|---|
| A file name | A module-map key is a resolved URL plus type. | Queries and fragments can make distinct keys. |
| A bare name | Needs an import-map mapping in browsers. | cart is not automatically ./cart.js. |
| preload | Downloads a resource into cache. | modulepreload also prepares a module result. |
| Worker module | Uses module loading semantics. | It is not a classic importScripts() worker. |
Practice exercises
Run the tiny URL program mentally, then type the full output URL.
console.log(new URL("../cart.js", "https://shop.example/js/main.js").href);It prints https://shop.example/cart.js because ../ leaves the js directory.
In one short phrase, say what happens when two modules import the same URL.
const modules = new Map();
modules.set("https://shop.example/js/cart.js", { loaded: true });
console.log(modules.has("https://shop.example/js/cart.js"));Two requests for the same module-map key receive the same module instance.
Your browser sees import "cart" and there is no matching relative file path. What does the page need?
<script type="importmap">{ "imports": { "cart": "/js/cart.js" } }</script>An import map gives the bare specifier a URL.
Your checkout always needs cart code but only discovers it after main starts. Name the loading hint to try.
<link rel="modulepreload" href="/js/cart.js">modulepreload prepares cart for a later import without evaluating it early.
Apply this to a real app: which Worker option lets a price-calculation worker import shared cart code?
new Worker("worker.js", { type: "module" });The module worker can use static imports and module loading semantics.
A module works locally but fails from a CDN. What should you inspect before changing the module code?
Inspect the Network response: URL, status, JavaScript Content-Type, and CORS headers. Then fix the server or resolution rule that failed.
Quiz
Use the module map for identity questions, URL resolution for path questions, and the fetch response for CORS or MIME questions.
Question 1 of 7What does the first URL example print?
Read the code, then predictconsole.log(new URL("./cart.js", "https://shop.example/js/main.js").href);Choose an answer to see the explanation.
Question 2 of 7Why do two imports of the same module URL share state?
Choose an answer to see the explanation.
Question 3 of 7What changes when
?v=2is added to an import URL?Choose an answer to see the explanation.
Question 4 of 7What does an import map's
importsobject do?Choose an answer to see the explanation.
Question 5 of 7What is special about a trailing-slash import-map key?
Choose an answer to see the explanation.
Question 6 of 7What does modulepreload do before a later import?
Choose an answer to see the explanation.
Question 7 of 7How do you create a module worker?
Choose an answer to see the explanation.
Key takeaways
- Browser modules are identified by resolved URL and module type in a per-document or per-worker module map.
- Relative specifiers resolve from the importing module URL; bare names need import-map help.
- Module fetches use CORS and JavaScript modules need a JavaScript MIME type.
- Import maps resolve names, while modulepreload prepares known modules earlier.
- Module workers support imports, and worklets load modules with
addModule().
A browser module load resolves a specifier to a URL, prepares one graph entry, then evaluates it when dependencies are ready.
Coming next: Module loading in Node.js.