Module loading in browsers
Learn how browsers resolve, fetch, cache, preload, and execute JavaScript modules with import maps, workers, and worklets.
- 01Tell modules apartExplain why two imports of the same URL share one module, but a URL with
?v=2gets a new one. - 02Find why an import failedCheck the URL, the file type, and CORS permission, in that order.
- 03Pick 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:
- It turns the text after
importinto a full web address, called a URL. - It downloads that file, and every file that file imports.
- It connects each
importto the matchingexport. This is called linking. - 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 givescarta URL? - Why does a server answer of
text/htmlstop a JavaScript import? - How can a preload hint make a later import faster without running the code early?
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.
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.jsin the same folder asmain.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.
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.
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.jsfor 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.truemeansfirstandsecondare the same object.2is the number of entries.1is how many times the?v=2entry was loaded.
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?
This replays the lesson's small model of the module map. It is not the browser's own code.
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'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 astext/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.
<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 tohttps://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.
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:
- the final URL of the file,
- the status, such as 200 or 404,
- the
Content-Type, and - 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.
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
cartthe URL/js/cart.js. - Line 2 also maps
lodash/, a name that ends in a slash. It works as a start of a name. Solodash/map.jsbecomes the/lib/lodash/folder plusmap.js. - Line 3 is a scope: a rule for files inside one folder. Files under
/admin/get their owncart,/admin/cart.js. - Lines 6 to 8 print the three full URLs.
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
./,../,/, orhttps: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, andmissing, a bare name with no key.
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: no key for this bare nameThe name cart now turns into https://shop.example/js/cart.js.
This replays the lesson's small model of the import map rules. The real browser rules handle a few more cases.
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 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.
<link rel="modulepreload" href="/js/cart.js">
<script type="module" src="/js/main.js"></script>- Line 1 starts getting
/js/cart.jsready early. - Line 2 still loads
main.jsas 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.
| Tool | What it does | What it does not do |
|---|---|---|
| Module map | Keeps one loaded module for each URL and type. | It does not make a second copy for the same URL. |
| HTTP cache | Keeps downloaded files so the browser can skip a download. | It does not keep the loaded module itself. |
| Import map | Turns a name such as cart into a URL. | It does not download the file. |
| modulepreload | Downloads, 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.jsonly after it starts readingmain.js. - With the hint, the download can start while the browser is still reading the HTML.
- Either way,
cart.jsruns 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.
// 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
totalfromcart.js. - Line 6 sends
totalback 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.
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
addModulewithawait. 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
modulepreloadfor 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.
- Turn
cartinto/js/cart.js. - Turn
./cart.jsinto a full URL. - Check that the server sent a JavaScript file type.
- Download and compile
cart.jsearly. - Run
main.jsafter the files it imports are ready. - Run the code in a module worker's file.
Put each action in the step where it happens. Read the explanation after each card.
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.
| Word | What it means | What it does not mean |
|---|---|---|
| File name | Only 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 name | A name like cart. It needs a key in an import map. | It does not mean ./cart.js. The browser never guesses. |
| preload | Downloads a file into the HTTP cache. | It does not get a module ready. modulepreload does that. |
| Module worker | A worker made with type: "module". It can use import. | It does not use importScripts(), as an old-style worker does. |
Practice exercises
Work out in your head what this one line prints. Then type the full 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 folder.
In a few words, say what two modules get when they both 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"));Both imports get the same module instance. An instance is one loaded copy of a module, and the module map keeps only one per URL and type.
Your page has import "cart". There is no ./ or / in front of the name. What does the page need?
<script type="importmap">{ "imports": { "cart": "/js/cart.js" } }</script>An import map gives the bare name cart a URL.
Your checkout page always needs cart.js. But the browser only finds it after main.js starts. Name the hint to try.
<link rel="modulepreload" href="/js/cart.js">modulepreload gets cart.js ready for a later import. It does not run it early.
In a real app, a worker works out prices. Which Worker option lets it import shared cart code?
new Worker("worker.js", { type: "module" });This makes a module worker. It can use import lines and the module rules.
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?
Look at the answer in the Network panel: the URL, the status, a JavaScript Content-Type, and the CORS headers. Then fix the server setting or the import map rule that failed.
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.
Question 1 of 7What does this line 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 7Two files import the same URL. Why do they share the same values?
Choose an answer to see the explanation.
Question 3 of 7What changes when you add
?v=2to an import URL?Choose an answer to see the explanation.
Question 4 of 7What does the
importsobject in an import map do?Choose an answer to see the explanation.
Question 5 of 7What is special about an import map key that ends in a slash?
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 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.
modulepreloadgets a known module ready early. - Module workers can use
import. Worklets load modules withaddModule().
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.