import defer
The import defer declaration behaves like regular import declarations, except that it results in a deferred module namespace object. The module and its dependencies are fetched and linked up front, but their synchronous evaluation is deferred until the namespace's properties are accessed. Modules that use top-level await are evaluated eagerly.
Syntax
import defer * as name from "module-name";
name-
Name that will refer to the deferred module namespace object. Must be a valid JavaScript identifier.
module-name-
The module to import from. Handled the same way as the
module-namein regularimportdeclarations.
Import attributes are also supported, using a with clause after the module specifier.
defer is not a reserved word. For example, import defer from "./module.js" is still a regular default import whose local binding is named defer.
Description
By default, the import declaration performs many tasks at once: resolving the module specifier, fetching the module source code, parsing (potentially discovering transitive dependencies), linking, and evaluating it. This form of eager evaluation is not always desirable: it may cause slower startup, the environment for its evaluation may not be fully prepared, or the module may not need to be evaluated at all.
The import phase modifier allows the module import process to stop at a particular phase. By adding defer after import, the source code is linked but remains unevaluated, provided that it can be evaluated synchronously (i.e., does not use top-level await). Accessing an export through the deferred namespace synchronously evaluates the module and any dependencies that need to be evaluated before it. The access returns the export's value after evaluation finishes. This executes the module's top-level code, not just the code needed to initialize the requested export. Transitive dependencies imported with their own import defer declarations can remain deferred.
By ensuring that the paused subgraph can be evaluated synchronously, import defer can be "dropped in" with almost no code changes to the places that use the module:
// Before:
import * as ts from "typescript";
// The full typescript module graph evaluates here
function compileFile(path) {
const program = ts.createProgram([path], {});
}
// After:
import defer * as ts from "typescript";
// No code is evaluated; potentially faster startup
function compileFile(path) {
// The typescript module graph evaluates when `ts.createProgram`
// is accessed, i.e., when `compileFile` is called.
// If `compileFile` is never called, then the module graph
// is never evaluated.
const program = ts.createProgram([path], {});
}
Warning: Deferring an import changes when its side effects occur. Do not defer modules whose side effects are needed before the rest of your code runs, such as modules that install polyfills.
Unlike import source, a deferred module is still linked up front. Linking up front lets the module loader resolve dependencies, catching missing dependencies or invalid imports before the module is used. Leaving the module unlinked avoids loading dependencies you may not need and allows you to control how it is instantiated.
Unlike import(), the deferred module is still fetched, parsed, and linked up front, avoiding unnecessary async coloring (entire chain of function calls forced to become async). import defer also enjoys most benefits of a static declaration, such as better static analysis.
Note that only the "namespace import" syntax is supported. You cannot use import defer { property } from "./my-module.js", etc., because the execution is triggered by property access on the namespace object.
Caching semantics
The modifier applies to an import, not to the module itself. If another part of the application imports the same module without defer, the module is evaluated as usual. Both forms share the same module state, and the module's code executes at most once. Changing the import phase does not create a separate module in the cache:
import defer * as ts from "typescript";
// No code is evaluated
import * as ts2 from "typescript";
// The full typescript module graph evaluates here
function compileFile(path) {
// Accessing `ts.createProgram` no longer evaluates the subgraph
// because it's already evaluated.
const program = ts.createProgram([path], {});
}
In contrast, import attributes can affect module identity. For example, in a host that supports text modules, these two declarations request different module types:
import * as mod from "./module.js";
import text from "./module.js" with { type: "text" };
The two imports are considered to be from different modules that happen to share the same string specifier (on the web, they will be requested with different HTTP headers). The supported attributes and their effects on loading and module identity are defined by the host.
Deferred module namespace object
A deferred module namespace object behaves much like a regular module namespace object: it has a null prototype, is non-extensible and sealed, and exposes read-only live bindings to the module's exports. Its string keys are enumerable and sorted in lexicographic order. The default export is available as the property named default.
There are three differences from a regular namespace:
- Operations that inspect exports can trigger evaluation and throw evaluation errors, as described below.
- Its
[Symbol.toStringTag]property is"Deferred Module"instead of"Module". This remains the case after evaluation. - It does not expose an export named
then, even after evaluation. Readingnamespace.thenalways returnsundefined. This prevents promise resolution from treating the namespace as a thenable and triggering evaluation. To access such an export, use a regular import, or introduce an intermediate module that re-exportsthenunder a different name.
The deferred and regular namespaces for the same module are distinct objects, even after evaluation. Repeated deferred imports of the same module, whether static or dynamic, share the same deferred namespace object.
In order to implement the behavior of "triggering module evaluation when keys are accessed", the deferred module namespace is essentially a Proxy that intercepts the following actions to trigger module evaluation:
-
defineProperty()for any string key other thanthen: for example,Object.defineProperty(namespace, "value", {}).Note: Because the module namespace object is non-extensible and sealed, you can't meaningfully add or change any property descriptor, including its value. Nevertheless, even if the operation fails, evaluation is still triggered.
The proxy does not intercept
set(). Setting properties likenamespace.value = 1;always fails. -
deleteProperty()for any string key other thanthen: for example,delete namespace.value.Note: You cannot actually delete any property that the namespace has. Nevertheless, even if the operation fails, evaluation is still triggered.
-
get()andgetOwnPropertyDescriptor()for any string key other thanthen: for example,namespace.value,namespace["missing"],const { default: value } = namespace,Object.getOwnPropertyDescriptor(namespace, "value").Note: Destructuring an export at the top level therefore defeats the deferral of that module.
-
has()for any string key other thanthen: for example,"value" in namespace,Object.hasOwn(namespace, "missing"). -
ownKeys(): for example,Object.keys(namespace),Object.getOwnPropertySymbols(namespace),for (const key in namespace) {}. Even enumerating only symbol keys triggers evaluation.
Merely referring to the namespace, assigning it to another variable, comparing its identity, or passing it to a function does not trigger evaluation. Neither does reading then or a symbol-keyed property, such as namespace[Symbol.toStringTag]. Calling Object.getPrototypeOf() or Object.isExtensible() does not trigger evaluation either. However, Object.isSealed() and Object.isFrozen() enumerate keys and therefore do trigger evaluation.
Top-level await
Reading a namespace property is synchronous, so it cannot wait for asynchronous module evaluation. Modules that contain top-level await are evaluated eagerly, along with the dependencies required to evaluate them. This includes modules reached through further deferred imports. The importing module waits for this asynchronous evaluation before running its own body.
If the directly imported module contains top-level await, its evaluation is not deferred. If only some of its dependencies contain top-level await, those dependencies are evaluated eagerly, but the synchronous parts of the graph that are not required for their evaluation can remain deferred. See Deferring a module with an asynchronous dependency.
Errors
Loading, parsing, and linking errors are not deferred. For example, a missing module, a syntax error in a dependency, or an unresolved named import prevents the importing module from running, even if the deferred namespace is never accessed. Errors from eagerly evaluated asynchronous dependencies also prevent the importing module from running.
Errors thrown during deferred evaluation are thrown synchronously by the operation that triggers evaluation. You can catch them with try...catch around that operation. The error is cached: subsequent operations that trigger evaluation throw the same error instead of retrying the module's code. This also applies if another import previously caused the module's evaluation to fail.
An operation that triggers evaluation throws a TypeError if the module or its dependencies are not ready for synchronous evaluation. This can happen with cyclic imports, when an access would require a module that is still being evaluated. An import defer declaration does not make every cyclic dependency safe to access during initialization. A readiness failure itself does not mark the requested module as having failed evaluation: a later access can succeed once its dependencies are ready.
Examples
>Evaluating a module on first use
The following module initializes a lookup table when its top-level code runs:
// -- squares.js --
console.log("Initializing squares");
const squares = Array.from({ length: 10000 }, (_, index) => index ** 2);
export function getSquare(index) {
return squares[index];
}
The importing module can expose a synchronous function without initializing the table until it is needed:
// -- main.js --
import defer * as squares from "./squares.js";
console.log("Ready");
export function showSquare(index) {
console.log(squares.getSquare(index));
}
showSquare(3); // Logs "Initializing squares", then 9
showSquare(4); // Logs 16; initialization is not repeated
"Ready" is logged before "Initializing squares". If showSquare() is never called and no other import causes squares.js to be evaluated, the lookup table is never initialized.
Deferring a module with an asynchronous dependency
In this example, report.js (which itself is synchronous) depends on both an asynchronous configuration module and a synchronous formatting module.
// -- config.js --
export const locale = await Promise.resolve("en-US");
console.log("Configuration ready");
// -- format.js --
console.log("Formatting module evaluated");
export function format(value, locale) {
return new Intl.NumberFormat(locale).format(value);
}
// -- report.js --
import { locale } from "./config.js";
import { format } from "./format.js";
console.log("Report module evaluated");
export function createReport(value) {
return format(value, locale);
}
// -- main.js --
import defer * as report from "./report.js";
console.log("Main module evaluated");
console.log(report.createReport(1000));
The output is:
Configuration ready Main module evaluated Formatting module evaluated Report module evaluated 1,000
config.js is evaluated before main.js because it contains top-level await. Neither format.js nor report.js needs to run to evaluate config.js, so their evaluation is deferred until report.createReport is accessed.
Catching evaluation errors
// -- broken.js --
export const value = 1;
throw new Error("Initialization failed");
// -- main.js --
import defer * as broken from "./broken.js";
for (let attempt = 0; attempt < 2; attempt++) {
try {
console.log(broken.value);
} catch (error) {
console.log(error.message); // "Initialization failed" on both attempts
}
}
Even though value was initialized before the exception was thrown, accessing it through the deferred namespace throws the cached evaluation error.
Exporting a deferred namespace
There is no export defer syntax (see export for more information). You can import a deferred namespace and then export its binding without triggering evaluation:
// -- features.js --
import defer * as report from "./report.js";
export { report };
// -- main.js --
import { report } from "./features.js";
// Accessing an export through report triggers its deferred evaluation.
console.log(report.createReport(1000));
Specifications
| Specification |
|---|
| Deferred Imports Evaluation> # sec-left-hand-side-expressions> |