cf.completefrontendCode editorOpen lab
THE JAVASCRIPT FIELD GUIDE

Module loading in browsers

Learn how browsers resolve, fetch, cache, preload, and execute JavaScript modules with import maps, workers, and worklets.

By the end, you can
  • 01
    Trace module identityExplain why matching module URLs reuse one module instance while URL variants do not.
  • 02
    Debug loading failuresCheck resolution, CORS, and JavaScript MIME types in the right order.
  • 03
    Choose 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?

DefinitionA module specifier is the text inside an import. The browser resolves it to a URL, then uses that URL and module type to find or create a module-map entry.

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.

Resolve sibling and parent module URLsPop out in the code editor (opens in a new tab)JavaScript
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.

A small module-map teaching modelPop out in the code editor (opens in a new tab)JavaScript
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.

Step through one browser module-map model
Step 0 of 7Ready
Your turn: follow the blue line

Replay instrumented teaching code. It models browser module-map identity; it is not an engine debugger.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 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);
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Real-life analogyYour phone downloads list

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.

A module response the browser can acceptHTML
<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.example

The 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.

Exact names, prefixes, and a scopePop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogyYour phone contact list

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.

Playground: change one import-map mapping
Resolver modelJavaScript
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"));
Resolved specifiers
cart -> https://shop.example/js/cart.js
lodash/map.js -> https://shop.example/lib/lodash/map.js
./cart.js -> https://shop.example/js/cart.js
missing -> error: unmapped bare specifier
Try it yourself

The exact cart mapping now resolves to https://shop.example/js/cart.js.

This is a small resolver model. It shows exact mapping, longest prefix mapping, relative URLs, and an unmapped bare-name error.
Step through import-map resolution
Step 0 of 4Ready
Your turn: follow the blue line

Replay the lesson resolver model. The full HTML algorithm has additional normalization and error cases; this model keeps the important choices visible.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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"));
CallStoreChangeResultRun = next line. Ran = already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Prepare cart before main needs itHTML
<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.

Browser loading tools that sound similar
ToolWhat it doesWhat it does not do
Module mapStores a module result by URL and module type.Same key reuses one module instance.
HTTP cacheMay reuse response bytes according to HTTP caching.It is not the JavaScript module instance.
Import mapTurns a specifier into a URL before loading.It does not download the module by itself.
modulepreloadFetches, 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.

A module worker imports cart codeJavaScript
// 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.

Load an audio worklet moduleJavaScript
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.

Resolve, prepare, or execute?
  • Map cart to /js/cart.js.
  • Turn ./cart.js into 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.
Try it yourself
0 of 6 correct

Sort each action by the loading phase it describes.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.
Terms to keep separate
TermAccurate meaningDo not confuse it with
A file nameA module-map key is a resolved URL plus type.Queries and fragments can make distinct keys.
A bare nameNeeds an import-map mapping in browsers.cart is not automatically ./cart.js.
preloadDownloads a resource into cache.modulepreload also prepares a module result.
Worker moduleUses module loading semantics.It is not a classic importScripts() worker.

Practice exercises

Exercise 1 · Warm-upPredict a parent URL

Run the tiny URL program mentally, then type the full output URL.

Starter codePop out in the code editor (opens in a new tab)JavaScript
console.log(new URL("../cart.js", "https://shop.example/js/main.js").href);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName shared identity

    In one short phrase, say what happens when two modules import the same URL.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const modules = new Map();
    modules.set("https://shop.example/js/cart.js", { loaded: true });
    console.log(modules.has("https://shop.example/js/cart.js"));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFix a bare import

      Your browser sees import "cart" and there is no matching relative file path. What does the page need?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePrepare a known dependency

        Your checkout always needs cart code but only discovers it after main starts. Name the loading hint to try.

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeEnable worker imports

          Apply this to a real app: which Worker option lets a price-calculation worker import shared cart code?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeAudit a failed import

            A module works locally but fails from a CDN. What should you inspect before changing the module code?

            Answer, then press Check. Spacing and letter case don’t matter.

              Quiz

              Use the module map for identity questions, URL resolution for path questions, and the fetch response for CORS or MIME questions.

              Browser module loading quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does the first URL example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                console.log(new URL("./cart.js", "https://shop.example/js/main.js").href);

                Choose an answer to see the explanation.

              2. Question 2 of 7Why do two imports of the same module URL share state?

                Choose an answer to see the explanation.

              3. Question 3 of 7What changes when ?v=2 is added to an import URL?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does an import map's imports object do?

                Choose an answer to see the explanation.

              5. Question 5 of 7What is special about a trailing-slash import-map key?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does modulepreload do before a later import?

                Choose an answer to see the explanation.

              7. 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.

              CompleteFrontend Clear concepts. Working examples.