Skip to content
Journal

Web Development · JavaScript

JavaScript's import defer: What It Actually Fixes

TC39's Stage 3 import defer proposal loads a module without running it until you touch it. What problem it solves and why you can't ship it yet.

Anurag Verma

Anurag Verma

6 min read

A paper-cut shelf of closed boxes in cream, navy and mustard with one terracotta trunk tilted open behind them

Sponsored

Share

Most of a large application’s slow startup time isn’t spent loading JavaScript anymore. Bundlers, HTTP/2, and CDN caching solved that half. It’s spent running JavaScript that got loaded but didn’t need to execute yet, like the fifteen locale files bundled for languages nobody in this session is using, or the admin-only feature module every user’s bundle includes just in case. TC39’s import defer proposal, currently at Stage 3, is aimed directly at that gap, and it’s worth understanding now even though nothing ships it yet.

What loading versus evaluating actually means

Every ES module import does two separate jobs. First, the engine loads the module: fetch it, parse it, and recursively do the same for everything it imports. Second, the engine evaluates it: run the module’s top-level code, in dependency order, so that exported values exist by the time anything imports them.

A normal static import does both immediately:

import { formatCurrency } from "./locales/de-DE.js";
// Loaded AND evaluated right now, whether or not you ever call formatCurrency

Dynamic import() defers both:

const { formatCurrency } = await import("./locales/de-DE.js");
// Not loaded until this line runs. Not evaluated until loading finishes.
// Always returns a promise, even if the module is already cached from elsewhere.

That second one is the part that creates friction. If your app already loaded de-DE.js earlier in the session, import() still hands you a promise, because the API can’t know in advance whether the target is cached. Every call site that might want deferred data has to become async, thread that async-ness through its callers, and pay a microtask tick even for a value that was sitting in memory the whole time.

The gap import defer is built for

import defer is a third option: load eagerly, evaluate lazily.

import defer * as deLocale from "./locales/de-DE.js";

// The module and its full dependency graph are already loaded.
// Nothing in de-DE.js has run yet.

function formatPrice(amount) {
  return deLocale.formatCurrency(amount); // <- evaluation happens HERE, synchronously
}

The module is fetched, parsed, and its dependency graph resolved at the same time a regular static import would do that work, which means bundlers and module loaders can still reason about the whole dependency graph ahead of time the way they do today. What doesn’t happen yet is running de-DE.js’s top-level code. That’s deferred until the first time something touches a property on the deLocale namespace object, at which point the module evaluates synchronously and the result behaves exactly like an ordinary import from then on. No promise, no await, no async coloring forced onto formatPrice.

This is aimed squarely at large applications carrying dozens of conditionally-needed modules, locale packs, feature flags gating a rarely-used admin panel, platform-specific code paths, where the cost that matters isn’t fetching the bytes. It’s running initialization code, registering listeners, building lookup tables, for modules that end up unused in a given session.

Why only the namespace form

The syntax is deliberately restrictive: import defer * as ns from "module". There’s no way to defer-import a single named binding the way you can with a regular static import (import { formatCurrency } from "..."). You get the whole module’s namespace object or nothing.

That’s not an oversight, it’s what keeps the semantics tractable. If you could defer-import one named export, the engine would need a way to evaluate just enough of a module’s top-level code to produce that one binding while leaving the rest unevaluated, and JavaScript modules don’t have a defined notion of “partial evaluation” to lean on. A module’s top-level code runs as one unit, in order, by design. import defer respects that by keeping the whole module as the unit of deferral: it has either run completely or not at all, and there’s no in-between state to reason about.

The escape hatch for top-level await

One edge case the proposal handles explicitly: a module using top-level await can’t stay deferred. Accessing a property on a deferred namespace object has to be a synchronous operation, by design, that’s the entire point of not forcing every access into a promise. But a module with a pending top-level await fundamentally can’t finish evaluating synchronously; something in its own dependency chain has to resolve first.

The proposal’s answer is a fallback: touching a deferred namespace object whose module (or one of its dependencies) uses top-level await triggers full eager evaluation instead, the same as if you’d used a regular static import in the first place. You don’t get an error and you don’t get the deferral benefit. It just quietly reverts to normal behavior for that specific module, which is a reasonable compromise: the failure mode is “you get the old behavior,” not “your app crashes on a spec edge case.”

Where this actually stands

import defer is at Stage 3 in TC39’s process as of this writing, which means the specification text itself is considered stable and unlikely to change further, and implementers are expected to start building it. Stage 3 is not “shipped.” There’s a reference implementation in engine262, the spec-compliant JavaScript interpreter TC39 uses to validate proposals against real execution semantics, and a Babel plugin exists for people who want to experiment with the syntax today by transpiling it away. Neither of those is a production runtime. No browser engine and no Node.js release currently executes import defer natively.

If you’re evaluating whether to plan around this, the honest read is: understand the shape of it now, because Stage 3 proposals generally do reach Stage 4 and ship, similar to how using and await using declarations moved through the same pipeline before landing in engines. But don’t write code today that depends on import defer executing correctly anywhere except a spec-conformance test suite or a transpiled build you control end to end.

The concrete takeaway

import defer closes a real gap between static imports (eager on both loading and evaluation) and dynamic import() (deferred on both, and always async). It’s one of several TC39 proposals worth tracking through the pipeline right now, alongside the Signals proposal reshaping state management. It gives you eager loading, so your bundler and module graph analysis still work the way they do today, paired with lazy evaluation, so a module you conditionally need doesn’t pay its initialization cost in sessions where you never touch it. It’s not available anywhere you can ship to users yet. Bookmark the syntax, watch for it crossing into Stage 4, and in the meantime keep reaching for dynamic import() for genuinely optional code paths, since that’s the tool that actually works today for the same underlying goal of not running code you don’t need.

Frequently asked questions

What does import defer actually do?
It loads a module and fully resolves its dependency graph up front, the same network and parse work as a normal import, but skips running the module's top-level code until you access a property on the resulting namespace object. At that point, and only at that point, the module evaluates, synchronously, and behaves exactly like a normal import from then on.
How is this different from dynamic import()?
Dynamic `import()` defers both loading and evaluation, and returns a promise no matter what, which forces the calling code into an async path even if the module happens to already be in the module cache from an earlier load. `import defer` only defers evaluation. Loading still happens eagerly at parse time like a static import, and accessing the namespace object afterward is synchronous, not a promise. It solves a narrower problem: modules you know you need to load but might not need to run.
Can I use import defer in production right now?
No. As of its current Stage 3 status in TC39's process, the proposal has a reference implementation in the engine262 spec-compliant interpreter and a Babel plugin for experimentation, but no shipping browser engine or Node.js release implements it. Stage 3 means the specification text is considered stable enough for implementers to start building it, not that it has shipped. Treat any code using it today as a demo, not something you deploy.
Why can't you defer-import individual named exports?
The proposal only defines a namespace-object form, `import defer * as ns from 'module'`, deliberately. Deferring a single named export would require the engine to partially evaluate a module, running just enough top-level code to produce that one binding while skipping the rest, which reintroduces the exact execution-order complexity the proposal is trying to avoid. Getting the whole namespace object and deferring evaluation of the entire module as one unit keeps the semantics simple: either the module hasn't run yet, or it has, with nothing in between.
What happens if the deferred module uses top-level await?
It gets evaluated eagerly instead of deferred, because touching the namespace object must be a synchronous operation, and a module with an unresolved top-level await can't finish evaluating synchronously. The proposal's authors chose to fall back to eager evaluation in this case rather than throw or forbid it, so `import defer` on a top-level-await module still works, it just doesn't give you the deferral benefit you were reaching for.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored