Lesson 04 / 14
Dynamic Imports
The promise-returning import expression, conditional loading with a computed specifier, the module registry's single-evaluation guarantee, and the cost balance of code splitting.
Contents
The previous lesson showed that a synchronous call cannot wait for a loading operation that spreads out over time, and left the solution for later. This lesson takes up that solution: an import form that returns a promise, can be used in expression position, and can have its specifier computed at runtime.
The flexibility this form provides combines properties of both module systems: the standard form’s deterministic resolution and the call-based form’s conditionality. In exchange, it brings a new responsibility — loading now spreads out over time, and it can fail.
Import as an Expression
Dynamic import is an expression whose result is a promise. It can be used inside a function body, a condition, a loop — anywhere in the program — and called from either module system. The value the promise resolves to is the namespace object introduced in the previous lesson.
// file: pending.mjs const delay = await new Promise((settle) => setTimeout(() => settle('ready'), 10)); export const status = delay;
// file: pending-import.cjs — pending.mjs is the file above import('./pending.mjs').then((m) => { console.log('async load result:', m.status); }); console.log('call returned immediately');
$ node pending-import.cjs call returned immediately async load result: ready
The module that could not be loaded with a synchronous call in the previous lesson was loaded with this form. The output order explains the mechanism: the call returned immediately, the body kept running, and the callback ran once the module was ready. The promise model from the Asynchronous JavaScript and the Runtime course applies here too.
The syntax resembles a function call, but it is not a function: it cannot be assigned to another name or called without an argument. Its specifier, though, does not have to be constant.
A Computed Specifier
The measurement library can present its result in different formats. Each format is a separate module, and which one gets used is decided at runtime:
// file: formatters/plain.mjs console.log('plain.mjs loaded'); export function format(measurement) { return `word: ${measurement.wordCount}, sentence: ${measurement.sentenceCount}`; }
// file: formatters/table.mjs console.log('table.mjs loaded'); export function format(measurement) { return Object.entries(measurement) .map(([key, value]) => `${key.padEnd(16)}${value}`) .join('\n'); }
// file: report.mjs — uses the two formatter files above const measurement = { wordCount: 8, sentenceCount: 2 }; async function report(style) { const module = await import(`./formatters/${style}.mjs`); return module.format(measurement); } console.log(await report('plain')); console.log('---'); console.log(await report('table')); console.log('---'); console.log(await report('plain'));
$ node report.mjs plain.mjs loaded word: 8, sentence: 2 --- table.mjs loaded wordCount 8 sentenceCount 2 --- word: 8, sentence: 2
On the third call, plain.mjs was not loaded again. The runtime keeps every resolved
specifier in a module registry; the second request returns the already-evaluated
module from the registry. The single-evaluation guarantee from the previous two lessons
holds for dynamic loading too. The registry’s key is the resolved address: even if the
same file is reached through two different specifiers, only one entry is created.
The price of this flexibility is the same one from the call-based system. When the specifier is built from a template string, which file gets loaded cannot be known by reading the source. For a tool doing static analysis, the graph breaks: it either bundles the entire directory into the output, or none of it.
For this reason, when the choice is among a finite set, the form that keeps the specifier constant is preferred:
// file: fixed.mjs — uses the two formatter files above const condition = process.argv[2] === 'table'; const module = condition ? await import('./formatters/table.mjs') : await import('./formatters/plain.mjs'); console.log(module.format({ wordCount: 8, sentenceCount: 2 }));
$ node fixed.mjs plain.mjs loaded word: 8, sentence: 2
The behavior is the same, but both specifiers are constant. The tool can see both files and mark them as chunks.
Loading Can Fail
In the statement form, a missing module threw an error before the program started. In the dynamic form, loading happens at runtime, so failure is a catchable rejection:
// file: missing.mjs try { await import('./formatters/missing.mjs'); } catch (error) { console.log('caught:', error.code); } console.log('program continues');
$ node missing.mjs caught: ERR_MODULE_NOT_FOUND program continues
The program did not crash. This is useful for loading optional capabilities: when a plugin cannot be found, the main function can keep running. The same behavior also creates a responsibility — if a required dependency is loaded dynamically, its absence goes unnoticed until runtime. The relationship between binding time and the time an error surfaces is the one built in the How Computers Work course’s linking-and-loading lesson.
For a module loaded over the network, a missing file is not the only cause of rejection: a dropped connection, a timeout, and a bad response all lead to the same path. This is why dynamic-loading calls are places for the retry and backoff patterns from the Asynchronous JavaScript and the Runtime course.
The Structure of the Resolved Value
The namespace object the promise resolves to is the same as in the statement form; the
default export sits there under the name default. When a call-based module is loaded
dynamically, this object’s content is the result of the name inference from the
previous lesson:
// file: legacy-format.cjs function format(number) { return number.toFixed(2); } module.exports = { format, version: '1.0.0' };
// file: dynamic-cjs.mjs — legacy-format.cjs is the file above const m = await import('./legacy-format.cjs'); console.log('namespace keys:', Object.keys(m)); console.log('default:', m.default.version, '| named:', m.version);
$ node dynamic-cjs.mjs namespace keys: [ 'default', 'format', 'module.exports' ] default: 1.0.0 | named: undefined
The inference found the name format, but not version: one was tied to a declaration
through shorthand property syntax, the other was a direct string value. The only
reliable way to take a name from a call-based module is through default. The third
name in the key list is a convenience the runtime adds, and it is not portable.
The loading promise can also be bound to a value, giving the lazy-initialization pattern: it loads a module the first time it is needed and shares the same promise on later calls:
// file: lazy.mjs — formatters/table.mjs is the module defined above let pending; function getFormatter() { pending ??= import('./formatters/table.mjs'); return pending; } const [a, b] = await Promise.all([getFormatter(), getFormatter()]); console.log('same promise:', getFormatter() === pending); console.log('same namespace:', a === b); console.log(a.format({ wordCount: 8 }));
$ node lazy.mjs table.mjs loaded same promise: true same namespace: true wordCount 8
Code Splitting
The tool-level counterpart of dynamic import is code splitting. Wherever a bundler sees a dynamic import, it cuts the graph there and puts that branch into a separate output file — a chunk. The main output carries only the chunk’s address; the file is requested the first time it is needed.
The gain is a smaller initial-load cost: a part of an application that is rarely opened is never downloaded. The table formatter in the measurement library is an example of this — most calls use the plain format.
The cost has three items:
Extra round trip. The chunk is requested separately, at the moment it is needed. The moment the user is waiting is exactly the most expensive moment to measure.
Duplicated code. If two chunks use the same helper module, the tool either copies the code into both chunks or produces a third, shared chunk. Both add complexity.
Failure path. Every chunk is a point of failure; every dynamic-loading call needs an error handler.
This is why splitting is not the automatic result of every dynamic import, but a measurement-based decision. The time it saves has to exceed the cost of the extra request. Splitting a frequently used code path hurts performance.
When Not to Use It
The dynamic form does not replace the statement form. When a module is needed on every run and immediately, the statement form is the right choice: the dependency is written into the source, its absence is caught before the program starts, and tools can see the graph.
The dynamic form fits three situations: the module is only needed under a specific condition, which module will be needed is decided at runtime, or its loading can be deferred past the initial startup. Used outside these cases, it hides the graph from tools without providing any gain.
Summary
- Dynamic import is a promise-returning expression; it can be called in any position and from either module system, and the value the promise resolves to is a namespace object.
- The specifier can be computed at runtime; since a computed specifier makes static analysis impossible, the constant-specifier form is preferred when the choice is finite.
- The module registry preserves the single-evaluation-per-resolved-address guarantee in dynamic loading too.
- Loading failure is a catchable rejection; this means a gain for optional capabilities and a delayed error for required dependencies.
- Code splitting lowers the initial-load cost; in exchange it brings an extra request, duplicated code, and new failure paths, which is why it is a measurement-based decision.
Next Step
Up to this point, every specifier has been a relative file path. In real projects,
though, most imports use bare names like text-measurer, and the file system carries no
record of which file such a name corresponds to. The next topic takes up that
resolution: a package’s manifest file, the rule for looking up a bare name, and pinning
the installed tree down with a lock file.
To keep your progress and take notes, Log in
My notes
Log in to take notes.