Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close