Dynamic import & top-level await
Load JavaScript modules on demand, understand dynamic import promises, code splitting, top-level await, import.meta, import.meta.resolve, and current module-loading proposals.
- 01Load code when you need itUse import() to load a module later, read its exports after the promise is ready, build the file name in code, and catch a failed load.
- 02Know who waitsExplain why a module runs only once, how code splitting keeps the first download small, and which modules wait for top-level await.
- 03Find where a module livesUse import.meta.url and import.meta.resolve(), and recognize import defer and import source as proposals that are not ready yet.
Load a module only when you need it
In the last lessons, you wrote import at the top of a file. That is a static import. "Static" means fixed: the browser loads those files before your code runs. That is right for code you always need.
Some code is not always needed. Think of a chart screen, a text editor, an admin page, or a second language. Many visitors never open them. If the page loads them at the start, the first screen is slower for everyone.
Dynamic import solves this. You write import(specifier), like a function call. Here is how it works:
- It runs only when your code reaches that line.
- The specifier is the string that names the file, like
"./charts.js". Your code can build that string while the program runs. - It returns a
Promise. When the file is ready, the promise gives you the module's exports. - It works in normal scripts and in modules.
Picture a restaurant that cooks every dish on the menu before it opens the door. Food comes fast, but the kitchen cooks a lot of food that nobody orders. A smarter kitchen waits for an order. The guest gets an order number, and the dish comes out when it is ready.
- In real life: The kitchen cooks every dish before it opens
- In JavaScript: Static imports load every module before your code runs
- In real life: A guest orders one dish only when they want it
- In JavaScript:
import()starts loading only when that line runs - In real life: The guest gets an order number while the dish cooks
- In JavaScript:
import()gives back a promise while the module loads - In real life: The dish arrives, ready to eat
- In JavaScript: The promise gives you the module's exports
This lesson covers these tools, in this order:
import(), which loads a module when you ask.- Code splitting, which keeps extra code out of the first download.
- Top-level
await, which lets a module wait before it is ready. import.metaandimport.meta.resolve(), which tell a module where it lives.import deferandimport source, two new forms that are not ready for everyday use yet.
import() gives you a promise first
INTERACTIVERead the code first. import() does not hand you the exports straight away. It hands you a promise, and await waits for it. The promise then gives you the module namespace object. That is a read-only object with one property for each export of the module.
const menu = await import("./menu.js");console.log(menu.default);console.log(menu.price);In plain words, line by line:
- Line 1 loads
menu.jsand waits. Then it saves the module namespace object inmenu. - Line 2 reads
menu.default. A default export is just a property nameddefault. It printstea. - Line 3 reads the named export
price. It prints3.
import("./menu.js") gives you a promise right away. After await, you get the module's exports in one object: menu.default is "tea" and menu.price is 3.const pending = import("counter");console.log(pending instanceof Promise);const counter = await pending;console.log(counter.next());const again = await import("counter");console.log(again === counter);await import("broken");Click a button to import a module.
Each button runs real module code inside a small hidden page (an iframe). The modules are written into the page itself, so nothing is downloaded. The lesson shows a message only if it comes from that exact iframe.
sandbox. It runs only the code shown here, never code you type, and it makes no network requests.The lab shows four things to remember:
- The promise exists right away, before the module is ready. So you can show a "Loading" message while you wait, like with any promise from the Promises lesson.
- The promise gives you the namespace object, not the default export. To read a default export, write
module.default. - JavaScript keeps each module after it runs once. This is the module cache. A second import of the same URL gives you the same module, and its code does not run again. So its variables are shared, whether you load it with
importor withimport(). - If the file is missing, has a syntax error, or throws an error while it runs, the promise rejects, which means it fails. Put
await import(...)insidetry/catchto handle that.
JavaScript allows import("./locales/" + language + ".js"). But a bundler, the build tool that joins and splits your files before the site goes online, needs to find every possible file ahead of time. So it may want the files in one known folder. Also, never turn text that a user typed into a file path. Pick from a list of known files instead.
const loadLocale = { en: () => import("./locales/en.js"), hi: () => import("./locales/hi.js"),};const language = "en";const locale = await loadLocale[language]();console.log(locale.greeting);In plain words, line by line:
- Lines 1 to 4 make an object called
loadLocale. It lists every language file the app may load. Each value is a small function that imports one file. - Line 5 sets
languageto"en". - Line 6 calls only the English function, so only
./locales/en.jsis loaded. - Line 7 prints
Hello.
Each file path is written out in full. So a bundler can find both files and make a separate file for each one.
try { await import("./broken.js");} catch (error) { console.log(error.message);}broken.js throws an error while it runs. So the import on line 2 rejects. The catch block on line 3 catches the error, and line 4 prints feature failed. Without try/catch, nothing would handle the error.
Code splitting: load extra code later
STEP THROUGHimport() is part of the JavaScript language. Code splitting is what a bundler does with it. The bundler puts each dynamically imported module into its own file, called a chunk. Then the browser loads:
- the small main part of the app, called the shell, right away;
- each feature chunk later, when someone opens that feature.
This site works this way. Each lesson page is its own chunk. So opening this lesson does not load the code for every other lesson.
Carrying every bag everywhere slows you down. If you pack only what you need today, your first walk is light. On beach day, you go and get the beach bag.
- In real life: Carry clothes for a whole month everywhere
- In JavaScript: One huge file that loads before anything runs
- In real life: Pack today's clothes in a small bag
- In JavaScript: The shell and the current page load first
- In real life: Fetch the beach bag on beach day
- In JavaScript: A feature chunk loads when the user opens that feature
const loaded = new Set(["shell"]);let bytes = 42; async function openFeature(name) { if (!loaded.has(name)) { bytes = bytes + featureBytes[name]; loaded.add(name); } return name + " ready";}The shell downloads first. Charts and Editor are separate chunks, so their bytes download only if you open them.
async function openCharts(wasClicked) { if (!wasClicked) return "waiting"; const charts = await import("./charts.js"); return charts.open();} console.log(await openCharts(false));console.log(await openCharts(true));In plain words, line by line:
- Line 7 calls
openCharts(false), which means "no click yet". Line 2 returns"waiting", and nothing is imported. - Line 8 calls
openCharts(true), which acts like a click. - Line 3 loads
charts.jsand waits for it. - Line 4 calls its
open()export, so line 8 printschart opened.
A real button would show a "Loading" message while line 3 waits.
<link rel="modulepreload" href="./charts.js"><script type="module" src="./app.js"></script>A modulepreload link asks the browser to download a module early and get it ready. It does not run the module. import() still decides when the module runs. Use it only when the user will probably open that feature soon. If they don't, you downloaded bytes for nothing.
The next replay shows two choices, step by step. Try each one:
- Load on click downloads nothing until the user clicks.
- Preload on hover starts the download when the pointer moves over the button. It costs bytes earlier, but the click may feel instant.
Pick when the charts file loads, then step through to see when its bytes are downloaded.
script
button.addEventListener("click", async () => { const charts = await import("./charts.js"); charts.renderDashboard();});Top-level await: a module can wait before it is ready
INTERACTIVENormally you can use await only inside an async function. Top-level await means writing await directly in a module, outside any function. It works only in modules, not in normal scripts.
When a module uses it, the module pauses at that line. Then:
- Modules that import it wait until it finishes.
- Modules that don't import it keep going.
const settings = await fetch("./settings.json").then((response) => response.json());export const theme = settings.theme;console.log(theme);Line 1 fetches settings.json and waits for the answer. Line 2 exports theme from those settings. So a module that imports theme always gets a ready value. If the fetch fails, that import fails instead. This example needs a real settings.json file, so here you can only read it.
A shop owner fills the shelves before unlocking the door. People who came for that shop wait outside. The bakery next door doesn't wait for anyone. It opens on time.
- In real life: The shop sets up before it unlocks the door
- In JavaScript: The module waits for its setup before its exports are ready
- In real life: Customers of that shop wait in line
- In JavaScript: Modules that import it wait
- In real life: The bakery next door opens on time
- In JavaScript: Modules that don't import it keep going
config.js pauses at its await. app.js imports it, so it waits. sibling.js imports nothing from it, so it runs right away.// config.jsconst started = performance.now();await new Promise((resolve) => setTimeout(resolve, 350));export const config = "ready after " + Math.round(performance.now() - started) + "ms"; // app.jsimport { config } from "config";post("app can run only after config: " + config); // sibling.jspost("sibling module ran without waiting for config");app.js imports config.js, so it waits for config.js to finish. sibling.js doesn't import config.js, so it runs before the timer ends.
Use top-level await with care:
- It is a good fit for one-time setup that every export of the module needs.
- It is a bad fit for a small helper file that every page imports. A slow wait there holds up every page that uses it.
import.meta and import.meta.resolve()
INTERACTIVESometimes a module needs to know where its own file is. import.meta is an object with facts about the current module. The browser or Node fills it in. Its most useful property is import.meta.url: the full URL of the current module file.
Say an image sits in the same folder as your module. Write new URL("./tea.svg", import.meta.url) to get its full URL. new URL(name, base) joins a file name to a base address. The URL objects lesson covers it in depth.
const iconUrl = new URL("./tea.svg", import.meta.url);console.log(iconUrl.href.endsWith("/tea.svg"));Line 1 uses this module's URL as the base. So the result stays right even when the page URL changes. Line 2 prints true, because the full URL ends in /tea.svg. The same trick works for workers and other files near the module.
new URL() joins the file name ./tea.svg to this module's own address, import.meta.url. The result is a full URL that ends in /tea.svg.import.meta.resolve(specifier) answers one question: "What full URL would this name point to from this module?" It only works out the URL. It does not load the file.
Say a module needs a helper file, an image, or a worker script next to it. Guessing from the page's address can give the wrong place. Asking from the module's own badge gives the right one.
- In real life: A badge at an event says who you are and where you belong
- In JavaScript:
import.meta.urlsays the URL of this module - In real life: You ask staff, "Where is Room B from here?"
- In JavaScript:
import.meta.resolve("./helper.js")asks where a name points from this module
The module prints its own URL. Then it works out the full URL of a nearby file. Last, it tries a bare package name that nothing points to, and that throws an error.
import.meta.resolve() returns right away in current browsers and current Node. It only works out the URL; it doesn't load the file.In current browsers and current Node, import.meta.resolve() returns a string right away, not a promise. It can still throw an error. A bare name is a name with no ./ or / in front, like missing-package. It throws when nothing tells the browser where that package lives.
import defer and import source are proposals
TC39 is the group that decides on new JavaScript features. A proposal is an idea for a new feature. Each proposal moves through stages from 0 to 4. At Stage 4, it becomes part of JavaScript.
The TC39 list puts two module proposals at Stage 3: Deferring Module Evaluation and Source Phase Imports. Stage 3 means the design is nearly done and browsers are trying it out. It does not mean every browser runs it today. So the code below is for reading only.
// Display-only proposals, not runnable lesson codeimport defer * as charts from "./heavy-charts.js";import source parser from "./parser.wasm"; // Today: use import() or your bundler's documented features instead.What each line is for:
- Line 2,
import defer, loads the module and links it now. But it waits to run the module until your code first reads something fromcharts. - Line 3,
import source, gives you the module before it runs, such as a WebAssembly module. WebAssembly is a fast, compiled kind of code that browsers can run.
Normal browsers may not run either line without extra tools. Today, the tool that works everywhere is still import().
Sort the need: static import, import(), or top-level await?
INTERACTIVEThe table compares the three tools. Read each row across: when the tool does its work, what you get, and when to use it.
| When you write | When does it do its work? | What do you get? | When should you use it? |
|---|---|---|---|
Static import | It loads the file before your module runs. | You get the exported names, ready to use. | Use it for code the module always needs. |
Dynamic import() | It loads the file when that line runs. | You get a promise for the module namespace object. | Use it for extra features, pages, or a file name built in code. |
Top-level await | It pauses the module while the module runs. | You get no new value by itself. The module just waits. | Use it for one-time setup that the exports need. |
Then try the sorter. Use these rules:
- The first screen can't work without it? Use a static
import. - A click or a setting chooses it later? Use
import(). - The module's own exports are not usable until setup finishes? Think about top-level
await.
- You always need
formatPricebefore the page can show - Load an admin panel only after a click on the Admin button
- Pick
./locales/${language}.jsfrom a setting - Read a settings file before this module's exports can be used
- Share fixed values that almost every module uses
- Start a WebAssembly helper before exporting
compress
Put each need under the tool that fits it best.
Where you'll use this
You'll use import() wherever a feature is useful but not always needed. Common cases:
- Pages: settings pages, admin tools, and dashboards should not slow down the home page.
- Heavy widgets: charts, text editors, maps, code highlighters, and video tools can load after a click.
- Language files: load only the language the user picked.
button.addEventListener("click", async () => { button.disabled = true; const { openEditor } = await import("./editor-panel.js"); openEditor(document.querySelector("#draft"));}); async function loadLocale(language) { return import("./locales/" + language + ".js");}In plain words, line by line:
- Line 2 turns the button off, so more clicks do nothing while the editor loads.
- Line 3 imports
editor-panel.js, a fixed file name. The braces take theopenEditorexport out of the namespace object. - Line 4 calls
openEditorwith the draft section. - Line 8 builds the file name from a
languagesetting.
In a project with a bundler, keep all language files in one known folder. Then the bundler can make a chunk for each one.
Common misconceptions
Here is the most common mistake. Line 2 points out the bug, and lines 3 and 4 show the fix.
const chartPromise = import("./charts.js");// chartPromise.render() is a bug: await the promise first.const charts = await chartPromise;charts.render();"import() gives me the exports right away."
It gives you a promise right away. Use await or .then() before you read the exports.
"Every import() makes a fresh copy of the module."
No. JavaScript keeps each module after it runs once. Importing the same URL again gives you the same module.
"Top-level await freezes the whole page."
No. Only modules that import the waiting module wait. Other modules keep going.
"A bundler can split code at any function call."
No. A bundler needs a module boundary to make a separate chunk. Most often, that boundary is an import().
"Stage 3 syntax works in every browser."
No. Stage 3 means the design is nearly done, not that every browser supports it. Check your tools before you use it.
Practice: load code on purpose
5 EXERCISESWhat does the first console.log print?
const pending = import("data:text/javascript,export const answer = 32 + 10");
console.log(pending instanceof Promise);
pending.then((module) => console.log(module.answer));The first log is true, because pending is a promise. Later, when the module has loaded, the callback logs 42.
Run the program in a modern browser console or a Node module. What are the three log lines?
const source = "export let count = 0; export function next(){ count += 1; return count; }";
const url = "data:text/javascript," + encodeURIComponent(source);
const first = await import(url);
console.log(first.next());
const second = await import(url);
console.log(second.count);
console.log(first === second);The logs are 1, 1, and true. The second import gets the same module, so it sees the changed count, and first === second is true.
Look at the bug example above. Why is chartPromise.render() wrong? Then type the tool you should use to load an optional feature.
const chartPromise = import("./charts.js");
const charts = await chartPromise;
charts.render();chartPromise is a promise, not the module. Wait for it with await first. Then call the export.
Picture a dashboard with a menu, charts, a map, and a text editor. Write down which parts you would load at the start, and which parts you would import later.
// shell.js
renderNavigation();
button.addEventListener("click", async () => {
const { renderCharts } = await import("./charts.js");
renderCharts();
});The shell stays small and ready to use. The charts module loads only after the button click, and a loading message can cover the wait.
Write two lines that start a module worker from editor-worker.js. That file sits next to the current module.
const workerUrl = new URL("./editor-worker.js", import.meta.url);
const worker = new Worker(workerUrl, { type: "module" });Line 1 finds the worker file next to the module that creates it. So the code stays right even if the page URL changes. Line 2 starts the worker.
Quiz: dynamic imports and waiting modules
7 QUESTIONSQuestion 1 of 7What does
import()give back right away?Choose an answer to see the explanation.
Question 2 of 7What does this code log first?
Read the code, then predictconst pending = import("data:text/javascript,export const answer = 32 + 10"); console.log(pending instanceof Promise); pending.then((module) => console.log(module.answer));Choose an answer to see the explanation.
Question 3 of 7What happens when you import the same module URL twice?
Choose an answer to see the explanation.
Question 4 of 7Which modules wait for a module that uses top-level await?
Choose an answer to see the explanation.
Question 5 of 7What is
import.meta.url?Choose an answer to see the explanation.
Question 6 of 7What does
import.meta.resolve()return in current browsers and Node?Choose an answer to see the explanation.
Question 7 of 7How should you treat this lesson's
import deferandimport sourceexamples?Choose an answer to see the explanation.
Key takeaways
import()returns a promise. The promise gives you the module namespace object, which holds the exports.- You can build the file name in code, but bundlers work best with a fixed list or folder.
- A module runs only once. Importing the same URL again gives you the same module.
- Code splitting keeps the code for extra features out of the first download.
- Top-level await works only in modules. It makes the modules that import it wait, and no others.
import.meta.urlis the module's own URL.import.meta.resolve()turns a name into a full URL and returns a string right away.import deferandimport sourceare proposals. Learn to recognize them, but don't expect every browser to run them yet.
Remember the one-liner.
Static imports load what a module always needs. import() loads extra code when you ask for it. Top-level await lets a module finish its setup before others use it.
Up next: Module resolution & import maps.