---
title: 'Dynamic Imports'
source: 'https://academia.sh/en/courses/javascript-ecosystem/dynamic-imports'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```javascript
// file: pending.mjs
const delay = await new Promise((settle) => setTimeout(() => settle('ready'), 10));
export const status = delay;
```

```javascript
// 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:

```javascript
// file: formatters/plain.mjs
console.log('plain.mjs loaded');

export function format(measurement) {
  return `word: ${measurement.wordCount}, sentence: ${measurement.sentenceCount}`;
}
```

```javascript
// 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');
}
```

```javascript
// 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:

```javascript
// 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**:

```javascript
// 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:

```javascript
// file: legacy-format.cjs
function format(number) { return number.toFixed(2); }

module.exports = { format, version: '1.0.0' };
```

```javascript
// 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:

```javascript
// 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.
