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 on demandUse import() as a promise for a module namespace, including computed specifiers and rejection handling.
- 02Reason about waitingExplain module caching, code splitting, and how top-level await delays only dependent modules.
- 03Inspect module identityUse import.meta.url and import.meta.resolve(), and recognize import defer and import source as proposals.
Load modules when the app actually needs them
Static imports are wonderful when code is required immediately. A module says, “before I run, load these dependencies.” That gives browsers and bundlers a clear graph. But real applications also have features that might never be opened: a chart screen, a markdown editor, an admin panel, a language pack, or a payment widget.
Dynamic import is the expression import(specifier). It runs when normal JavaScript reaches that expression, accepts a string specifier that can be computed at runtime, and returns a Promise for the module namespace object. It works in classic scripts and in modules, because it is an expression rather than a top-level declaration.
Imagine a restaurant that prepares every possible dish before unlocking the door. Service is instant, but the kitchen wastes time on food nobody orders. Dynamic import lets the kitchen wait for a real request. The guest gets an order token, and the dish arrives when it is ready.
- In real life: The restaurant preps every dish before opening
- In JavaScript: Static imports load required modules before evaluation
- In real life: A guest orders a special dish only when they want it
- In JavaScript:
import()starts loading only when that line runs - In real life: You receive an order token while food is prepared
- In JavaScript:
import()returns a promise while the module loads - In real life: The dish arrives ready to serve
- In JavaScript: The promise fulfills with the module namespace object
Where the analogy stops: Restaurants can improvise with ingredients. JavaScript still resolves a precise module specifier, and bundlers often need a bounded pattern for computed paths.
This lesson covers the whole advanced module-loading toolkit: import(), code splitting, top-level await, import.meta, import.meta.resolve(), and two proposal-shaped imports you should recognize without treating them as everyday browser syntax.
import() returns a promise for a module namespace
INTERACTIVERead the code first. Line 1 does not give you the exports directly. It gives you a promise. After await, line 3 receives the module namespace: a read-only object whose properties are the module’s exports. If the same normalized URL is imported again, JavaScript reuses the already evaluated module instance.
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");Choose a control to import a module.
The iframe runs real module code with an import map whose specifiers point to predefined data URL modules. The parent accepts messages only from that exact sandboxed frame.
allow-scripts and srcdoc. No learner code or network request is evaluated.Notice four professional details in the lab:
- The promise is created immediately. This is why you can show a loading state or race it against a timeout just like promises from the Promises lesson.
- The fulfillment value is the namespace object, not the default export. If a module has a default export, read it as
module.default. - Modules are cached per URL after successful evaluation. Shared module state is shared whether the import was static or dynamic.
- Broken loading, parse errors, missing dependencies, or a thrown top-level error reject the promise. Use
try/catcharoundawait import(...).
JavaScript allows import("./locales/" + language + ".js"). Bundlers may require a pattern they can discover, such as all files in a known locale folder. Keep user input bounded; do not turn arbitrary text into a module path.
Code splitting: move optional bytes to later
STEP THROUGHDynamic import is language syntax. Code splitting is what build tools do with it: turn a module boundary into a separate file, often called a chunk. Instead of downloading an entire application before the first screen, the browser can download the shell now and feature code later. This very site is split per route by its bundler, so opening this lesson does not require the code for every other lesson.
Carrying everything everywhere slows you down. Packing by trip day keeps the first walk light, while still letting you fetch the beach bag when beach day arrives.
- In real life: Carry every outfit for a month
- In JavaScript: One huge bundle loaded before anything runs
- In real life: Pack day-one clothes in a small carry-on
- In JavaScript: The shell and current route load first
- In real life: Open another packing cube when plans change
- In JavaScript: A feature chunk loads when the user opens it
Where the analogy stops: Suitcases are physical; downloaded code can be cached and reused. The analogy is about timing, not literal storage.
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 is paid up front. Charts and Editor are separate feature chunks, so their bytes move to later unless the learner opens them.
The next replay models the same idea as execution order. Try both settings. Loading on a click saves every byte until the user asks. Preloading on hover spends bytes earlier, but the eventual click may feel instant.
Choose a loading strategy, then step through when bytes move from later to now.
script
button.addEventListener("click", async () => { const charts = await import("./charts.js"); charts.renderDashboard();});Top-level await: a module can pause its own evaluation
INTERACTIVETop-level await means await directly inside a module, not inside an async function. It is allowed only in modules. When a module uses it, that module’s evaluation is pending until the awaited work settles. Modules that import it wait. Unrelated modules do not.
If the chart module cannot export a ready chart until it has loaded a WebAssembly helper or configuration file, top-level await can express that one-time setup directly.
- In real life: A shop finishes setup before unlocking the front door
- In JavaScript: A module awaits configuration before its exports are ready
- In real life: Customers for that shop wait in line
- In JavaScript: Importers that depend on the module wait
- In real life: The bakery next door can still open
- In JavaScript: Sibling modules with no dependency can continue
Where the analogy stops: A real shop may let people browse during setup. A module’s importers cannot run dependent code until evaluation finishes.
// 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");The importer waits for config because it depends on it. The sibling module does not depend on config, so it can report before the timer finishes.
Use top-level await carefully. It is excellent for module-level setup that every export truly needs. It is harmful when placed in a central utility imported by every route, because then a slow setup step can delay much more of the dependency graph than you intended.
import.meta and import.meta.resolve()
INTERACTIVEA module sometimes needs to know where it lives. import.meta is the host’s metadata object for the current module. The most common property is import.meta.url, the current module’s URL. Use it with new URL("./asset.svg", import.meta.url) when an asset sits next to the module. The URL objects lesson covers that constructor in depth.
When a module needs a nearby helper, image, or worker script, guessing from the page URL is wrong. Ask from the module’s own badge instead.
- In real life: A conference badge says who you are and where you belong
- In JavaScript:
import.meta.urlsays which URL this module has - In real life: Asking the venue where Room B is from here
- In JavaScript:
import.meta.resolve("./helper.js")asks where a specifier points
Where the analogy stops: A badge does not open the room. import.meta.resolve() resolves a URL string; it does not load or evaluate that module.
The module reports its own URL, resolves a nearby relative specifier, and shows that an unmapped bare package name is not magic.
import.meta.resolve() is synchronous in current browsers and current Node. Resolving does not load the target.In current browsers and current Node, import.meta.resolve() returns a string synchronously. It can still throw, for example when a bare package name has no import map or package resolution rule in that environment.
import defer and import source are proposals
The TC39 proposals repository currently lists Deferring Module Evaluation and Source Phase Imports under Stage 3. Stage 3 means the committee is far along and wants implementation feedback. It does not mean the syntax is safe to paste into every browser today. Treat the following as display-only recognition code.
// 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.import defer is about linking now but deferring evaluation until the namespace is used. import source belongs to source-phase imports, a proposal for working with module source values such as WebAssembly modules before normal evaluation. Without a tool or an experimental implementation, ordinary browsers should not be expected to run either form. Today’s portable tool is still import().
Sort the need: static import, import(), or top-level await?
INTERACTIVE| Pattern | When it runs | What you get | Best for |
|---|---|---|---|
Static import | Before the importing module evaluates | Imported live bindings | Required dependencies and tool-friendly graphs |
Dynamic import() | When that expression runs | A promise for the module namespace object | Optional features, routes, and computed specifiers |
Top-level await | During module evaluation | No new value by itself; it pauses that module | One-time async setup before exports are ready |
Try the sorter. A good rule: if the first screen cannot work without it, static import it. If a user action or setting chooses it later, use import(). If a module’s own exports are unusable until setup finishes, consider top-level await.
- Always need
formatPricebefore rendering the route - Load an admin panel only after an Admin button click
- Pick
./locales/${language}.jsfrom a setting - Read startup config before this module's exports are usable
- Share constants used by nearly every module
- Initialize a WebAssembly helper before exporting
compress
Place each need under the pattern that best communicates timing and dependency.
Where you’ll use this
Dynamic imports show up anywhere a feature is useful but not guaranteed. Route-level code splitting is the most common case: settings pages, admin tools, and dashboards should not slow the public landing page. Heavy widgets are another: charting libraries, rich text editors, maps, syntax highlighters, and media tools can load after a clear click.
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");}The first import has a fixed specifier and opens a feature. The second computes a specifier from a setting. In a bundler project, you would keep the possible language files in a known folder so the tool can create safe chunks.
Common misconceptions
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 immediately.”
It gives a promise immediately. Await it before reading exports, or use .then().
“Dynamic import always creates a new module instance.”
Successful modules are cached by URL. Importing the same URL twice gives the same module instance.
“Top-level await freezes the whole page.”
It delays only modules in the dependency chain. Sibling modules can continue.
“Code splitting is automatic for any function call.”
Bundlers need a module boundary, often a dynamic import, to create a separate chunk.
“Stage 3 proposal syntax is production browser syntax.”
Stage 3 is promising, not universal support. Use documented tooling before relying on proposals.
Practice: load code deliberately
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, after the data URL module loads, the callback logs 42.
Run the program in a modern browser console or 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 receives the same module instance, so it sees the changed count and strict equality is true.
In the misconception snippet above, why is chartPromise.render() wrong? Type the pattern that should be used for optional features.
const chartPromise = import("./charts.js");
const charts = await chartPromise;
charts.render();The bug is treating the promise like the namespace object. Await first, then call the export.
Imagine a dashboard with navigation, charts, a map, and a markdown editor. Write down which parts you would load up front 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 interactive. The charts module moves behind the button click, where a spinner or skeleton can cover the wait.
Write the two-line pattern for creating a module worker that lives next to the current module as editor-worker.js.
const workerUrl = new URL("./editor-worker.js", import.meta.url);
const worker = new Worker(workerUrl, { type: "module" });The worker URL is resolved relative to the module that creates it. That keeps the code correct even if the page route changes.
Quiz: dynamic imports and waiting modules
7 QUESTIONSQuestion 1 of 7What does dynamic import return immediately?
Choose an answer to see the explanation.
Question 2 of 7What does this data URL dynamic import 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 the same normalized module URL is imported 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 current
import.meta.resolve()return in browsers and modern Node?Choose an answer to see the explanation.
Question 7 of 7What should this lesson's
import deferandimport sourceexamples be treated as?Choose an answer to see the explanation.
Key takeaways
import()is an expression that returns a promise for the module namespace object.- The specifier can be computed at runtime, but bundlers may need a bounded pattern.
- Successfully evaluated modules are cached per normalized URL.
- Code splitting moves optional feature bytes from startup to a later user need.
- Top-level await is module-only and delays dependent importers, not unrelated modules.
import.meta.urlis the module’s URL;import.meta.resolve()synchronously resolves specifiers in current browsers and Node.import deferandimport sourceare proposal shapes to recognize, not plain browser assumptions today.
Remember the one-liner.
Static imports load what this module always needs; import() loads optional code when asked; top-level await lets a module finish setup before importers use it.
Up next: Module resolution & import maps.