Published on 8 min read
ECMAScript - Introducing Deferred Module Evaluation with import defer
TL;DR
“import defer” is a stage 3 TC39 proposal that returns a module's namespace without evaluating it: the module's top-level code only runs the first time a property is read off that namespace.
Every year, an edition of the ECMAScript Language Specification is released with the new proposals that are officially ready. In practical terms, the proposals are attached to the latest expected edition when they are accepted and reach stage 4 in the TC39 process:

One of the more interesting stage 3 proposals is import defer (previously known as “Lazy Module Initialization”). Although the proposal is still a stage 3 candidate, its design is effectively complete and very close to being finalized toward stage 4.
In this article, we're going to examine this proposal and see how it's meant to work.
Motivation
A lot of the perceived slowness in large JavaScript applications isn't about downloading code - it's about running it. Once the bundle is downloaded, the JavaScript engine has to walk through every module's top level and execute the code that is there. This can burn a significant number of milliseconds before anything actually renders - especially for big apps.
We already have decent tools for the download side. Preloading avoids request waterfalls and Dynamic Imports lets us pull in a module only when we need it. But none of that touches execution cost: if a module runs expensive setup code the moment it's loaded, it's going to run that code no matter how cleverly we fetched it.
Node’s CommonJS synchronous ecosystem already ran into this using require(): we can move it from the top of a file into a specific function without changing the function’s API, and the module will only be initialized when that function actually runs.
For instance, this:
const chartRenderer = require('chart-renderer');
exports.renderReport = function (data) {
return chartRenderer.render(data);
};
Can turn into:
exports.renderReport = function (data) {
const chartRenderer = require('chart-renderer');
return chartRenderer.render(data);
};
Nothing changes for the functions that call renderReport - they still call the same function and get the same (synchronous) result back. The only difference is that chart-renderer doesn’t get loaded and initialized until someone actually renders a report.
ES modules don't have a good equivalent to do it without paying a price. Dynamic import can delay loading a module, but it forces us to transform the code into a Promise chain:
export async function renderReport(data) {
const { render } = await import('chart-renderer');
return render(data);
}
This code creates two headaches:
- Dynamic import doesn’t really improve things on the network side. By the time
renderReportruns, we usually wantchart-rendererto have already been downloaded and sitting in the cache. Soimport()ends up acting mostly as a way to delay execution, not as a way to reduce what we download. - Dynamic import makes
renderReportnow async - whether it needs to be or not. This means that every function that calls it should be async all the way up. That’s not a small refactor but rather an API change we have to align across the entire call chain.
Dynamic import gives us lazy loading, not lazy execution - it saves bytes on the network, but it can't keep a synchronous API while still delaying when a module's top-level code runs.
That's the gap import defer is aiming to close: eager loading with lazy execution and an API that stays synchronous the whole way through.
The Proposal
The proposal introduces a defer modifier to import that returns a deferred namespace object instead of running the module right away:
import defer * as ns from "some-module";
We still download the module and wire up all of its imports and exports right away. If some-module (or anything it depends on) fails to fetch or has a syntax error, we find out immediately, exactly as with a regular import.
The thing that is deferred with the new syntax is module evaluation.
some-module's top-level code doesn’t run until we actually read something from ns. That first read runs the whole module synchronously and after that ns behaves just like a normal namespace:
import defer * as ns from "some-module";
// "some-module" has been downloaded and linked
// but its top-level code has not run yet
function useFeature() {
return ns.feature(); // reading this is what triggers evaluation
}
Syntax
import defer only comes in the namespace form. This means there's no way to defer individual named imports like import defer { feature } from "some-module". We always get the whole namespace object (and never separate bindings).
There's also a dynamic version that mirrors import() :
const ns = await import.defer("some-module");
This import.defer() call resolves once the module has been downloaded and wired up - not when its top-level code has run. It gives us the same deferred-evaluation behavior as import defer * as ns, but in the cases where we can’t use a static import (for example, when the module specifier is only known at runtime).
Top-Level Await
Property access on a deferred namespace has to stay synchronous, so a module can't have its evaluation deferred if it (or anything it depends on) uses top-level await. This means there's no way to synchronously wait on a Promise that hasn't settled.
import defer handles this by looking at the deferred module's own dependency graph and running anything async in it right away. Only the purely synchronous leftovers of that graph actually get deferred.
Here's a small graph that shows this in action. Let’s say a is the entry point:
// a.js
import "b";
import defer * as c from "c";
setTimeout(() => {
c.value;
}, 1000);
// c.js
import "d";
import "f";
export let value = 2;
// d.js
import "e";
await 0;
A few things to notice:
dhas a top-levelawaitso its evaluation can’t be deferred.eis a dependency ofdand also can’t be deferred - bothdanderun eagerly when the module graph first loads.- At the same time,
b(imported with a regularimport) andaitself also run eagerly as part of the initial load.
The only modules that remain deferred are c and f. These modules don’t run until the first time c.value is accessed later on.
In other words, any module that uses top-level await (and its dependencies) runs upfront; only fully synchronous modules can actually benefit from deferred evaluation.
Different Error Handling
Another fact to know is that a deferred namespace handles errors differently from a regular one.
Let’s say we have a module that throws an error while evaluating. A regular import * as ns namespace won't re-throw that error on a later property access - it just returns whatever got exported before the throw.
In contrast, a deferred namespace will re-throw the original error every time we touch it:
// a-deferred-module-that-throws.js
export let a = 1;
throw new Error("oops");
import defer * as ns from "a-deferred-module-that-throws.js";
try {
ns.a;
} catch (e) {
console.log(e.message); // "oops"
}
The reason for this behavior is consistency.
When we use import defer, other parts of the app might have already evaluated the same module using a regular import. If the error only showed up sometimes - depending on who touched the module first - it would be very confusing.
To avoid that, import defer * as ns does not reuse the same namespace object as a plain import * as ns. Instead, it gives us a separate namespace that always re-throws the evaluation error on access, no matter when evaluation happened.
Where Things Stand
As of writing, import defer sits at stage 3. The syntax and semantics are already settled - what's left is mostly the committee working through edge cases around cyclic dependency graphs and async evaluation ordering.
Engine work has already started: V8 has it behind a flag in Chrome, Deno already enabled it by default, and Node.js support is in progress. JavaScriptCore has it flagged too and it's enabled by default in Bun. SpiderMonkey support is in progress as well.
For anyone who wants to try it today, there's also an experimental implementation in engine262, a webpack PR and a Babel plugin that transforms the syntax down to something that runs today.
Summary
We covered the idea behind the import defer proposal and how it's meant to work.
Let's sum up:
- The proposal is currently at stage 3 in the TC39 process
- Dynamic
import()solves lazy loading, but not lazy execution - and forces an async API onto every consumer import defer * as ns from "module"loads and links a module eagerly, but defers evaluating its top-level code- Evaluation happens the first time a property is accessed on the resulting namespace object
- Modules that use top-level await can't have their evaluation deferred - their async dependencies are evaluated eagerly instead
import.defer("module")gives us a dynamic API with the same deferred-evaluation semantics as the static form, for cases where the module name is only known at runtime- A deferred namespace always re-throws an evaluation error on access, unlike a regular namespace, so behavior doesn't depend on load timing
- Deno and Bun already ship it by default, Chrome has it behind a flag and Babel offers a plugin for today
Once the proposal reaches stage 4 and lands in a future edition, it should give us a real alternative to the "lazy require" pattern many of us already reach for in Node.js - without giving up a synchronous API in the process.
You’re welcome to share:
Enjoyed this post?
I’d love for you to follow me and join my newsletter.
Comments are powered by DisqusDetails
Loading comments activates Disqus, which collects information as a third-party service.
The site owner has no access to or control over the information collected by Disqus.
Related Posts

ECMAScript - Introducing BigInt Primitive in ES2020 (ES11)
5 min read
Introducing the "BigInt" proposal, a new primitive of arbitrary precision integers, which has been reached stage 4 in the TC39 process and is included in the language specification of 2020 - the 11th edition.

ECMAScript - Introducing Dynamic Imports in ES2020 (ES11)
6 min read
Introducing the "Dynamic Import" proposal, arriving with new import() keyword enabling to load a module on demand at runtime, which has been reached stage 4 in the TC39 process and is included in the language specification of 2020 - the 11th edition.

npm - Catching Up with Package Lockfile Changes in v7
7 min read
Introducing the changes that were done in the seventh version of npm for better performance while allowing deterministic and reproducible builds, focusing on the new package-lock.json format (v2) and Yarn's lockfile support.