JAVASCRIPT / MODULES AND DEVELOPER TOOLING
Dynamic import for loading code on demand
Load modules at runtime with import(), await the namespace object, split heavy code into on-demand chunks, and handle load failures without crashing.
What you will learn
- Call import(specifier) at runtime and await the namespace object it resolves to
- Build specifiers from variables to reach a module a static import cannot express
- Rely on the registry: repeat imports of one URL never re-evaluate the module body
- Catch rejections from import() so a failed load degrades instead of crashing
Understanding Dynamic import for loading code on demand
A static import declaration is part of a module's shape: the engine collects every declaration, resolves and fetches all of them, and links them before the first line of your code executes, which is why the specifier must be a string literal and cannot sit inside an if. import(expression) is different syntax with different timing — it looks like a function call, runs when control reaches it, accepts any expression as the specifier, and hands back a promise that resolves to the module's namespace object. That one change moves two decisions from load time to run time: whether a module is loaded at all, and which module it is.
The mental model is a registry keyed by resolved URL. The first import() of a URL triggers fetch, linking, and exactly one evaluation of the top-level body; the resulting namespace object is stored, and every later import() of the same resolved URL resolves to that same object without re-running anything. So loading on demand really means evaluating once, at the first demand — a click handler that fires twenty times pays for one load. Relative specifiers resolve against the module containing the import(), not against the page URL or the working directory.
Bundlers treat each import() as a code-splitting boundary: the target module and its private dependencies go into a separate chunk that is requested only when the expression runs, which is how a small first paint can coexist with a heavy editor, chart library, or PDF exporter. The cost is that the work now happens at an awkward moment, while the user waits after a click, so it pays to start the import earlier than you strictly need it — on hover, or when the main thread goes idle — and let the registry make the real use instant. It also means the bundler must see enough of the specifier at build time: import('./locales/' + code + '.js') gives it a pattern to emit chunks for, while import(pathFromServer) gives it nothing.
// demo.mjs — run: node demo.mjs
const source = 'data:text/javascript,' + encodeURIComponent(`
console.log('module body runs now');
export const greet = (name) => 'hi ' + name;
export default 'the default export';
`);
console.log('before import()');
const pending = import(source);
console.log('import() returned a', pending.constructor.name);
const mod = await pending;
console.log('resolved');
console.log(mod.default);
console.log(mod.greet('ada'));
console.log('exported names:', Object.keys(mod).join(','));
const again = await import(source);
console.log('second import, same object:', again === mod);
Dynamic import defers a module's resolution and evaluation to the moment the expression runs, returning a promise for a namespace object that the registry then caches under that URL.
Worked examples
Choosing the module at runtime
A computed specifier loads one locale module and leaves the other untouched.
// locales.mjs — run: node locales.mjs
const asModule = (code) => 'data:text/javascript,' + encodeURIComponent(code);
const locales = {
en: asModule(`console.log('evaluating en'); export default (n) => 'Hello, ' + n;`),
de: asModule(`console.log('evaluating de'); export default (n) => 'Hallo, ' + n;`)
};
async function greet(locale, name) {
const { default: format } = await import(locales[locale]);
return format(name);
}
console.log(await greet('de', 'Mira'));
console.log(await greet('de', 'Tom'));
Example explained
Line 1asModule wraps source text in a data: URL only to keep the demo in one file; in a real project these values would be './locale-en.js' and './locale-de.js'.
Line 2locales[locale] is a computed specifier, and a static import declaration cannot accept one — that is the reason to use import() here at all.
Line 3'evaluating en' never appears: the English module is never fetched and its body never runs, because that branch was never taken.
Line 4'evaluating de' appears once although greet runs twice, since the second import() finds the existing registry entry for that exact URL.
Failures arrive as rejections
A missing module and a module that throws both reject the import() promise instead of stopping the program.
// failures.mjs — run: node failures.mjs
const boom = 'data:text/javascript,' + encodeURIComponent(`throw new Error('bad config');`);
try {
await import('./not-here.js');
} catch (err) {
console.log('missing module:', err.code);
}
await import(boom).catch((err) => console.log('threw while evaluating:', err.message));
const second = await import(boom).catch((err) => err.message);
console.log('same error again:', second);
console.log('the program is still running');
Example explained
Line 1try/catch works around import() only because of the await; without it the call returns a promise and the catch block never sees anything.
Line 2err.code is ERR_MODULE_NOT_FOUND because resolution failed, so none of the target module's code ever ran.
Line 3An error thrown by the module body becomes the rejection reason of the import() promise, which is why .catch() can handle it.
Line 4The second import of the same URL rejects with the same error and does not re-run the body — the module record remembers its evaluation error.
Important notes
The optional second argument carries import attributes, not your own options: JSON needs await import('./data.json', { with: { type: 'json' } }).
A module that throws while evaluating stays broken for the process or page, so a retry has to change the URL, as in './m.js?v=2', and that URL loads a second independent copy of the module.
Common mistakes
Using the resolved value as the export: const chart = await import('./chart.js'); chart() throws 'chart is not a function', because the promise resolves to a namespace object, so you need chart.default or a destructured named export.
Forgetting the promise entirely: const m = import('./m.js'); m.run() fails with 'm.run is not a function', since m is still a pending Promise at that point.
Feeding import() a specifier the build cannot see, such as import(urlFromApi), or a path written relative to the HTML page instead of the importing module — it works in dev and 404s in production.
Try it yourself
Change, predict, then run
In a <script type="module"> page with one button, make the click handler run const { nanoid } = await import('https://esm.sh/nanoid') and log nanoid(). Open the Network panel and click three times to confirm the module is fetched once while every click logs a new id.
Open the JavaScript workspaceCheck your understanding
A click handler calls await import('./chart.js'), and chart.js logs 'setup' at its top level. The user clicks the button three times. What does the console show, and why?
- 'setup' once, because the registry keys the module by resolved URL and later imports reuse the already-evaluated namespace
- 'setup' three times, because each import() call re-runs the module body
- 'setup' three times, because import() returns a fresh promise per call and each promise evaluates the module
- 'setup' once, because the browser's HTTP cache serves the file from memory on the second and third click
Show answer
Module records are cached by resolved URL, so the second and third import() find an existing evaluated module and skip fetching and evaluation. Option 3 is tempting because it also predicts one 'setup', but it confuses two different caches: with HTTP caching disabled the count is still one, and an HTTP cache hit would at best avoid the download while still re-evaluating.