completefrontend
Open lab My progress
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
    Tell modules apartExplain why two imports of the same URL share one module, but a URL with ?v=2 gets a new one.
  • 02
    Find why an import failedCheck the URL, the file type, and CORS permission, in that order.
  • 03
    Pick the right loading toolKnow when to use an import map, modulepreload, a module worker, or a worklet.

How a browser gets a module ready

A module is a JavaScript file that uses import and export. When a page imports a module, the browser does four jobs in order:

  1. It turns the text after import into a full web address, called a URL.
  2. It downloads that file, and every file that file imports.
  3. It connects each import to the matching export. This is called linking.
  4. It runs the code. This is called evaluating.

The browser also keeps a list of the modules it has already loaded. If the same URL is asked for again, it uses the module it already has. It does not run the file a second time.

These jobs answer three questions you will meet in real apps:

  • Why does import "cart" fail until an import map gives cart a URL?
  • Why does a server answer of text/html stop a JavaScript import?
  • How can a preload hint make a later import faster without running the code early?
Definition

A module specifier is the text in quotes after import, such as "./cart.js". The browser turns it into a URL. Then it uses that URL and the module's type to find the module it already has, or to make a new one.

This lesson follows Top-level await & async evaluation. That lesson showed when a ready module runs. This one shows the work the browser does to get modules ready.

A module is found by its URL

A specifier that starts with ./ or ../ is relative. It only makes sense next to the address of the file that imports it. The browser does not look in your project folder or guess a package name. It joins the two addresses with the same rule the URL tool uses. new URL(path, base) builds a full address from a path and a starting address, called the base.

Find a file in the same folder and one folder upPop 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 looks for ./cart.js in the same folder as main.js.
  • Line 2 prints https://shop.example/js/cart.js.
  • Line 4 uses ../, which means "go up one folder".
  • Line 5 prints https://shop.example/cart.js.
Both addresses start from https://shop.example/js/main.js. Top: ./cart.js stays in the same folder and becomes https://shop.example/js/cart.js. Bottom: ../cart.js goes up one folder and becomes https://shop.example/cart.js.starting from https://shop.example/js/main.js./cart.jshttps://shop.example/js/cart.jssame folder../cart.jshttps://shop.example/cart.jsup one folder
Both start from the address of main.js. ./ stays in the js folder, and ../ goes up one folder.

So if you move a module to another folder, its relative imports point somewhere else. A bundler is a build tool that joins and rewrites your files before you publish them. It may change these addresses. But in the end the browser always gets URLs, and it finds every file from them.

Inside a browser module, import.meta.url holds the URL of that module. import.meta.resolve("./cart.js") gives back the full URL as a string. It uses the same rules as import, import maps included, but it does not download cart.js. Later, import("./cart.js") downloads the file when you ask. It gives back a promise for an object that holds all of the module's exports.

The module map: one entry per URL

The module map is the browser's list of modules it has loaded. Each page and each worker has its own list. Each entry is found by two things:

  • the URL the browser asked for, and
  • the module's type, such as JavaScript or JSON.

When another import asks for an entry that is already there, the browser gives back that same module. It does not make a second copy.

The code below is a small model of this list. It is not the real browser code. It uses a Map, a JavaScript object that stores values under keys. Here each key is a URL.

A small model of the module mapPop 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 12 loads cart.js for the first time. The model makes a new entry.
  • Line 13 asks for the same URL. It gets the same entry back.
  • Line 14 asks for cart.js?v=2. That is a different URL, so the model makes a second entry.
  • Line 15 prints true 2 1. true means first and second are the same object. 2 is the number of entries. 1 is how many times the ?v=2 entry was loaded.
Left: two tags that both say cart.js point to one cart whose card says visits: 2. Right: a tag that says cart.js?v=2 points to a second cart whose card says visits: 1.cart.jscart.jsvisits: 2cartone entrycart.js?v=2visits: 1cart 2a second entry
Two loads of the same URL get the same entry, so it has visits: 2. Adding ?v=2 makes a different URL, so the map makes a second entry.

The URL is what counts. The part after ? is called the query. /js/cart.js and /js/cart.js?v=2 have different queries, so the browser treats them as two keys. It can download and run both. But /js/../js/cart.js is just a longer way to write /js/cart.js. The browser cleans it up to the same URL, so it finds the entry it already has.

Two imports can also ask for an entry that is still loading. The second import waits for that work. It does not start loading the file again. When loading and linking finish, both imports see the same module and the same exported values.

The module map is not the HTTP cache. The HTTP cache is where the browser keeps downloaded files so it can skip a download. It follows its own rules. The module map answers a different question: does this page or worker already have a module for this URL and type?

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

This replays the lesson's small model of the module map. It is not the browser's own code.

Running in
  1. script
Next: line 1
 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);
CallStoreChangeResultBlue line: runs next. 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's downloads list

Your phone's downloads list keeps one item for each download. When you open that item again, the phone does not make a second copy. A different link makes a new item, even if the file name looks almost the same.

In real life: One item for one download link
In JavaScript: One module-map entry for one URL
In real life: Open the same item again
In JavaScript: Get the same module back
In real life: A different link is a new item
In JavaScript: A URL with ?v=2 is a new entry

Downloading a module: file type and permission

Once the browser has the URL, it downloads the file. Two checks must pass:

  • The file type. The server says what kind of file it sent in a header called Content-Type. This kind is called the MIME type. A JavaScript module needs a JavaScript MIME type, such as text/javascript. If the server sends HTML or another type, the import fails.
  • Permission. The origin of a URL is its scheme, host, and port, such as https://shop.example. If the file comes from another origin, that server must allow your page to read it. This permission system is called CORS (Cross-Origin Resource Sharing). Module downloads always use CORS.
A page imports cart.js, and the server answersHTML
<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
  • Lines 1 to 4 are the page. The script asks for ./cart.js.
  • Lines 6 to 8 are the server's answer. Line 7 gives a JavaScript Content-Type. Line 8 gives CORS permission to https://shop.example.

The server's answer is not code you can run, so this example has no Run button.

The browser is strict about module files. A common failure comes from a server that sends its normal HTML page, with status 200, when a .js file is missing. The file name still ends in .js. But the browser reads the Content-Type header, sees HTML, and will not make a JavaScript module from it.

Two server answers for cart.js. Left: Content-Type text/javascript, and the import works. Right: Content-Type text/html, and the import fails.server saystext/javascriptimport worksserver saystext/htmlimport fails
The browser reads the Content-Type header, not the file name. Only a JavaScript type can become a module.

Permission is a separate check. Say https://cdn.example hosts cart.js. Then that server must allow your page's origin through CORS. An import map, which you meet in the next section, only changes where the browser goes. It does not give permission to read the file there.

When an import fails on the live site, open the Network panel in DevTools first. Check these, in this order, before you change any code:

  1. the final URL of the file,
  2. the status, such as 200 or 404,
  3. the Content-Type, and
  4. the CORS headers.

This order tells you what went wrong: a wrong mapping, a missing file, a wrong file type on the server, or missing permission.

Import maps: short names for URLs

A specifier like cart, with no ./, ../, /, or https: in front, is called a bare name. On its own, the browser cannot turn a bare name into a URL.

An import map fixes this. It is JSON inside a <script type="importmap"> tag. It gives a name such as cart a URL before any file is downloaded. The page must read the import map before any module that uses its names.

The code below writes an import map as a JavaScript object. It then calls resolveSpecifier, a small model of the browser's rules that this lesson provides. It takes a name, the import map, and the URL of the importing file, and returns the full URL.

Exact names, names that end in a slash, 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 gives the exact name cart the URL /js/cart.js.
  • Line 2 also maps lodash/, a name that ends in a slash. It works as a start of a name. So lodash/map.js becomes the /lib/lodash/ folder plus map.js.
  • Line 3 is a scope: a rule for files inside one folder. Files under /admin/ get their own cart, /admin/cart.js.
  • Lines 6 to 8 print the three full URLs.
Top: a tag cart goes through the import map and becomes https://shop.example/js/cart.js. Bottom: a tag lodash/map.js goes through the import map and becomes https://shop.example/lib/lodash/map.js.import mapcarthttps://shop.example/js/cart.jslodash/map.jshttps://shop.example/lib/lodash/map.js
These are lines 6 and 7 of the code. The import map turns each short name into a full URL before anything is downloaded.
Real-life analogyYour phone's contact list

On your phone, you tap a short name, and the phone dials the full number. In the same way, an import map lets cart stand for /js/cart.js.

In real life: Tap a saved name
In JavaScript: Write a bare name, such as cart
In real life: The contact holds the full number
In JavaScript: The import map holds the full URL
In real life: A separate list of work contacts
In JavaScript: A scope with its own names

How the browser picks a rule:

  • If the name matches an exact key, that key wins.
  • Otherwise, it looks at keys that end in a slash. If several match the start of the name, the longest one wins.
  • If no key matches, a name that starts with ./, ../, /, or https: still works as a normal URL.
  • A bare name with no key throws an error.

An exact key matches only that exact name. Mapping cart does not map cart/price.js. When a package has many files, add a second key that ends in /, and end its URL with / too. That slash tells the browser to add the rest of the name to the end of the URL.

Scopes answer a different question: which file is doing the import? A rule under /admin/ only applies when the importing file's URL is inside /admin/. If several scopes fit, the browser tries the closest one first. Then it tries wider scopes. Last, it tries the top-level imports.

Try the import map yourself

Below, you can change only the URL for cart. The list then shows what four names turn into:

  • cart, an exact name,
  • lodash/map.js, a name that starts with a slash key,
  • ./cart.js, a relative path, and
  • missing, a bare name with no key.
Playground: change the URL for cart
The import map rulesJavaScript
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"));
What each name turns into
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: no key for this bare name
Try it yourself

The name cart now turns into https://shop.example/js/cart.js.

This is a small model of the import map rules. It shows an exact name, a key that ends in a slash, a relative path, and a bare name with no key.
Step through the import map rules
Step 0 of 4Ready
Your turn: follow the blue line

This replays the lesson's small model of the import map rules. The real browser rules handle a few more cases.

Running in
  1. script
Next: line 1
  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"));
CallStoreChangeResultBlue line: runs next. 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 real browser rules do a little more. They clean up keys that look like URLs. They also check the closest matching scope before the top-level imports. This small model keeps only the choices you need to spot when you fix a broken import.

Get a module ready early with modulepreload

<link rel="modulepreload"> is a hint in your HTML. It asks the browser to do three things early, before any import needs the module:

  • download the file,
  • parse it, which means read the code and check that it is valid, and
  • compile it, which means turn it into a form the engine can run.

The ready module goes into the page's module map. It runs later, when a normal import reaches it.

Get cart.js ready before main.js needs itHTML
<link rel="modulepreload" href="/js/cart.js">
<script type="module" src="/js/main.js"></script>
  • Line 1 starts getting /js/cart.js ready early.
  • Line 2 still loads main.js as a normal module.

The hint does not run cart.js early. It only moves the download and the preparing earlier, for a file you already know you will need.

Each row of this table is a browser tool with a similar name. Read across to see what it does and what it does not do.

Four loading tools with similar names
ToolWhat it doesWhat it does not do
Module mapKeeps one loaded module for each URL and type.It does not make a second copy for the same URL.
HTTP cacheKeeps downloaded files so the browser can skip a download.It does not keep the loaded module itself.
Import mapTurns a name such as cart into a URL.It does not download the file.
modulepreloadDownloads, parses, and compiles a module early.It does not run the module.

Plain preload only downloads a file into the HTTP cache. modulepreload follows the module rules and also gets the module ready. Preload only important modules that you will very likely need. Too many early downloads can slow down more important ones.

Use it when the page surely needs a file, but the browser would find it late. An example is a large module that main.js imports. It helps less for a feature a visitor may never open. There, an early download takes network speed away from the image, font, or main script the first screen needs.

Compare the order with and without the hint:

  • Without the hint, the browser finds cart.js only after it starts reading main.js.
  • With the hint, the download can start while the browser is still reading the HTML.
  • Either way, cart.js runs only when the normal imports reach it.

Modules in workers and worklets

A worker runs a JavaScript file in the background, apart from the page. It talks to the page by sending messages. A worker can be a module worker. Pass type: "module" when you make it. Then the worker file can use import lines and the module rules from this lesson. An old-style worker loads other files with importScripts() instead.

A module worker imports cart.jsJavaScript
// main.js
const worker = new Worker("worker.js", { type: "module" });

// worker.js
import { total } from "./cart.js";
self.postMessage(total);
  • Line 2, in main.js, makes a module worker.
  • Line 5, in the worker file, imports total from cart.js.
  • Line 6 sends total back to the page as a message.

A module worker's imports are downloaded with CORS, just like the page's.

The worker has its own global object and its own module map. The page's import map is not copied into it. So give the worker a full URL, and make sure every file the worker imports can be found from inside the worker. If a bundler builds your code, write new URL("./price-worker.js", import.meta.url). That gives the tool a clear link from your module to the worker file.

A worklet also loads JavaScript modules. It is a small, special place to run code for one browser feature, such as sound (an audio worklet) or drawing (a paint worklet). Its addModule() method gives back a promise. The promise finishes once the module has been added to the worklet.

Load a module into an audio workletJavaScript
const context = new AudioContext();
await context.audioWorklet.addModule("meter-processor.js");
  • Line 1 makes an AudioContext, the object that plays sound.
  • Line 2 waits for addModule with await. So the code after it can count on the module being there.

Real worklets also need browser support and a secure page, one served over https.

To tell them apart:

  • A worker runs code apart from the page and talks to it with messages.
  • A worklet is a small place with strict rules, made for one browser feature.
  • Both load modules. Neither one can see the page's import map or its global variables.

Which tool to use in a real app

  • Use a relative path, like ./cart.js, when one file really sits next to the other.
  • Use an import map when you want a short public name, like cart, that stays the same. It also helps when many names must point to a folder you control.
  • Use modulepreload for an important module that the browser would otherwise find late.

Check that a preload helps. Look at the Network panel, and at speed numbers from real visitors. A preload only makes a file ready sooner. It does not replace sending less code.

Find the URL, get it ready, or run it?
  • Turn cart into /js/cart.js.
  • Turn ./cart.js into a full URL.
  • Check that the server sent a JavaScript file type.
  • Download and compile cart.js early.
  • Run main.js after the files it imports are ready.
  • Run the code in a module worker's file.
Try it yourself
0 of 6 correct

Put each action in the step where it happens. Read the explanation after each card.

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

For a worker file that a build tool handles, build its URL from import.meta.url. Then the path starts from the current module, not from the HTML page. This also helps the bundler find the worker file.

Common mistakes about module loading

  • “The file name tells modules apart.” The browser uses the full URL and the module's type.
  • “An import map downloads code.” It only turns names into URLs. The download comes after.
  • “modulepreload runs code early.” It only gets the module ready. The code runs later.
  • “CORS is only for fetch().” Module downloads use CORS too.
  • “The page's import map works in workers too.” It does not reach modules loaded into workers or worklets.

Each row of this table is a word from this lesson. Read across to see what it means and what it does not mean.

Words that are easy to mix up
WordWhat it meansWhat it does not mean
File nameOnly part of the URL. The browser tells modules apart by full URL and type.It does not tell modules apart. cart.js and cart.js?v=2 are two modules.
Bare nameA name like cart. It needs a key in an import map.It does not mean ./cart.js. The browser never guesses.
preloadDownloads a file into the HTTP cache.It does not get a module ready. modulepreload does that.
Module workerA worker made with type: "module". It can use import.It does not use importScripts(), as an old-style worker does.

Practice exercises

Exercise 1 · Warm-upPredict the URL one folder up

Work out in your head what this one line prints. Then type the full 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-upTwo imports of the same URL

    In a few words, say what two modules get when they both 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 an import of a bare name

      Your page has import "cart". There is no ./ or / in front of the name. What does the page need?

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

        Exercise 4 · PracticeGet a needed file ready early

        Your checkout page always needs cart.js. But the browser only finds it after main.js starts. Name the hint to try.

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

          Exercise 5 · ChallengeLet a worker use import

          In a real app, a worker works out prices. Which Worker option lets it import shared cart code?

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

            Exercise 6 · ChallengeFind why an import failed

            A module works on your computer, but fails when it comes from a CDN, a server that hosts files for many sites. What should you check before you change the module's code?

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

              Quiz

              For "is it the same module?" questions, think of the module map. For path questions, join the URLs. For CORS or file type questions, think of the server's answer.

              Browser module loading quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does this line 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 7Two files import the same URL. Why do they share the same values?

                Choose an answer to see the explanation.

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

                Choose an answer to see the explanation.

              4. Question 4 of 7What does the imports object in an import map do?

                Choose an answer to see the explanation.

              5. Question 5 of 7What is special about an import map key that ends in a slash?

                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 make a module worker?

                Choose an answer to see the explanation.

              Key takeaways

              • The browser tells modules apart by full URL and type. Each page and each worker keeps its own module map.
              • A relative path starts from the URL of the importing file. A bare name needs an import map.
              • Module downloads use CORS. A JavaScript module needs a JavaScript MIME type.
              • An import map turns names into URLs. modulepreload gets a known module ready early.
              • Module workers can use import. Worklets load modules with addModule().

              The browser turns a name into a URL, gets one entry ready, and runs it once the files it imports are ready.

              Coming next: Module loading in Node.js.

              CompleteFrontend Clear concepts. Working examples.