Web Platform · JavaScript Runtime

Dynamic Import, Module Maps, and Top-Level Await

What import() actually schedules, how the module map keys records by URL, and why top-level await turns a synchronous graph into an asynchronous one.

Static imports are resolved before anything runs, which is what gives the module graph its all-or-nothing character: the price is paid up front. import() buys the opposite trade. It takes a specifier computed at runtime, fetches and parses the target on demand, and returns a promise for the module’s namespace object. Between the call and the settlement sit URL resolution, a network round trip, parsing, instantiation, and evaluation, and each of those steps has a defined place in the module map so that the same module is never evaluated twice.

The operator is syntactically an operator, not a function: it accepts a specifier expression and, where supported, a second argument carrying import attributes. Because its argument need not be a literal, static analysis cannot see the dependency; that is the cost of the flexibility, and it is why dependency graphs built from source text miss these edges.

What the operator does, in order

The evaluation of import(specifier) performs a host-defined sequence. In a browser, the steps are roughly: resolve the specifier relative to the importing module’s base URL; look the resulting URL up in the module map; if absent, fetch and parse the script, add the record to the map, and instantiate its dependencies; then evaluate the record, awaiting its completion if it is asynchronous; finally resolve the returned promise with the module namespace object.

Every step after the call is asynchronous, so even a module already in the map and fully evaluated settles its promise in a later job rather than synchronously. Code that depends on the namespace being available in the same task is relying on something the specification does not offer; the minimum delay is one microtask, and in practice a fetch dominates when the module is not cached.

Resolution follows URL semantics rather than path semantics. A specifier of ./util.js in a module served from /app/main.js resolves to /app/util.js; ../lib/x.js walks up; a bare specifier such as lodash has no default meaning in a browser and must be mapped by an import map, while in Node it is resolved through node_modules. The resolved URL is absolute, including the scheme and, if applicable, the query and fragment. This matters because those components are part of the map key: ./mod.js?v=1 and ./mod.js?v=2 are two distinct modules, each fetched, parsed, and evaluated separately, with separate top-level state.

The map is per environment, not per page

The module map lives on the environment settings object — the document or worker that is doing the loading. Two iframes on the same page at the same origin each have their own, since each document is a separate realm, so a shared dependency loads and evaluates once per frame rather than once per tab. The HTTP cache may collapse the fetches, but the module records and their side effects are not shared. Within one environment, the map is the authority: a second import of the same URL, static or dynamic, joins the existing record. If that record is still evaluating, the promise waits; if it failed, the failure is reported again.

Entries persist for the lifetime of the environment. There is no API to evict a module or force re-evaluation, so a module with top-level side effects runs exactly once per document, and a module that captured configuration at load time cannot be reconfigured by importing it again with a different query string — that produces a different module with its own copy of everything.

Failures split into two categories with different caching behaviour. A parse or resolution error poisons the record, and subsequent imports of the same URL reject with the same error. A network failure is a failure to obtain a record at all, so a failed fetch leaves no record behind and a later import may retry. Code that treats a rejected dynamic import as permanently fatal therefore overstates what the platform guarantees, though retrying without backoff is rarely better than failing.

Top-level await changes the graph’s shape

A module containing await at its top level is marked asynchronous. Its own bindings initialize normally until the await is reached, evaluation then suspends, and the rest of the body resumes as a promise job when the awaited value settles. Crucially, the module’s evaluation is not complete until the resumed portion finishes, and every module that imports it waits for that completion. The dependency order is still depth-first, but it is now a partial order with suspension points: an importer of an asynchronous module cannot have its own body run until the dependency’s body has fully run.

For dynamic imports the effect is local and easy to reason about: the promise from import() resolves after evaluation completes, so a caller that awaits it observes a fully initialized module. For static imports the effect propagates through the graph to the entry point. An HTML module script whose graph contains top-level await delays DOMContentLoaded until the await settles, because the parser waits for script evaluation and the document’s load event is gated on it. A page that awaits a slow fetch at the top level of its entry module therefore delays every later step of document loading.

The mechanism is also what makes a module able to do conditional setup before exporting anything usable:

// config.js
const response = await fetch('/config.json');
export const config = await response.json();
export const ready = true;

An importer reading config immediately after its own imports have been evaluated sees the resolved object, not a promise, because the importer’s evaluation was deferred until config.js finished. That is a real convenience over the CommonJS pattern of exporting an initialization promise and requiring every consumer to await it. The cost is that the deferral is invisible at the import site: a module that looks synchronous now has network latency in its evaluation path, and any test or tool that loads it must handle the asynchrony.

Cycles involving top-level await are the fragile case. With synchronous modules, a cycle is merely an order the programmer must understand; with asynchronous modules, an edge can mean “wait for the other module to finish”, and a cycle of such edges has no order in which the waits can be satisfied. Keeping awaited initialization out of modules that participate in cycles avoids the question entirely, and it is the only reliable rule, because the failure depends on which module the graph entered from.

Diagnostic surfaces

import.meta is a host-provided object available only inside modules. Its url property gives the absolute URL of the current module, computed at parse time; it is the standard way to locate sibling resources without hard-coding a path, since it reflects whatever the server actually served, including redirects. The object is per module record, so two modules see different url values, and a module loaded under two different query strings sees two different ones.

Import attributes, written with { type: 'json' } after the specifier in a static import and passed as the second argument to import(), tell the host how to interpret the response — a JSON module rather than a script, for example. The attribute describes the resource, not a variant of it, so one URL should not be expected to serve two module types depending on who imported it. Give each module type its own URL.

Neither the namespace object nor the map is reachable for introspection. There is no API to ask whether a URL is loaded, which modules depend on which, or how large the graph is; the browser’s performance entries expose the fetches, but page code cannot see the map itself. Code that needs to know whether a module is already evaluated must keep that bookkeeping itself, remembering the promise returned by the first import() and handing it to later callers rather than calling import() again. Sharing the promise also means one failure is shared, which is usually the desired semantics.

References

  1. ECMAScript Language Specification — Import Calls — Ecma International
  2. HTML Living Standard — Fetching a module script — WHATWG
  3. import() — MDN Web Docs — MDN Web Docs