Module Loading: How V8 Wires Up ES Modules

V8 turns a module file into three tables of Cells, resolves the import graph by DFS, and evaluates modules in post-order so that a cycle gets a well-defined answer.

TL;DR: A parsed module becomes a SourceTextModule with three parallel tables: regular_exports, regular_imports and requested_modules. Every export and every import is a Cell - a one-slot mutable box. Importing x from m does not copy x; it stores the same Cell in the importer’s regular_imports table, so a later write to m’s x is visible through the import. The graph is resolved by a DFS whose post-order gives you the spec’s evaluation order, and cycles are detected by the DFS ancestor index rather than by an explicit cycle finder.

An ES module is not a script with extra syntax. The difference is not parsing - V8 parses import and export with the same machinery it uses for everything else. The difference is linking: a module is a graph of bindings that have to be connected to each other before any of them can run, and the connection has to be a live connection, not a copy.

This is the part that trips people up. import { counter } from './m.js' does not take a snapshot of counter. If m.js later does counter = 5, the importer sees 5. If the importer does counter = 6, the exporter sees 6. That is not a language feature bolted on top; it falls out of how V8 represents module bindings.

Cells: the one-slot mutable box

The primitive that makes live bindings work is the Cell. A Cell is a heap object with one field:

cpp
// src/objects/module.tq:6
@cppObjectLayoutDefinition
extern class Module extends HeapObject {
  // The complete export table, mapping an export name to its cell.
  exports: ObjectHashTable;
  // Hash for this object (a random non-zero Smi).
  hash: Smi;
  status: Smi;
  module_namespace: Cell|Undefined;
  deferred_module_namespace: Cell|Undefined;

The export table is an ObjectHashTable mapping export name to Cell. The cell is the variable. If two modules refer to the same cell, they share the variable.

The sign of the cell index encodes the direction. From the parser’s descriptor:

cpp
// src/ast/modules.h:100
    // Import/export entries that are associated with a MODULE-allocated
    // variable (i.e. regular_imports and regular_exports after Validate) use
    // the cell_index value to encode the location of their cell.  During
    // variable allocation, this will be be copied into the variable's index
    // field.
    // Entries that are not associated with a MODULE-allocated variable have
    // GetCellIndexKind(cell_index) == kInvalid.
    int cell_index;

and the encoding itself:

cpp
// src/ast/modules.cc:285
SourceTextModuleDescriptor::CellIndexKind
SourceTextModuleDescriptor::GetCellIndexKind(int cell_index) {
  if (cell_index > 0) return kExport;
  if (cell_index < 0) return kImport;
  return kInvalid;
}

Exports get 1, 2, 3, ...; imports get -1, -2, -3, .... Zero is “not a module variable”. The assignment is a simple loop over the descriptor’s maps:

cpp
// src/ast/modules.cc:292
void SourceTextModuleDescriptor::AssignCellIndices() {
  int export_index = 1;
  for (auto it = regular_exports_.begin(); it != regular_exports_.end();) {
    auto current_key = it->first;
    // This local name may be exported under multiple export names.  Assign the
    // same index to each such entry.
    do {
      Entry* entry = it->second;
      DCHECK_NOT_NULL(entry->local_name);
      DCHECK_NULL(entry->import_name);
      DCHECK_LT(entry->module_request, 0);
      DCHECK_EQ(entry->cell_index, 0);
      entry->cell_index = export_index;
      it++;
    } while (it != regular_exports_.end() && it->first == current_key);
    export_index++;
  }

The comment “This local name may be exported under multiple export names” is the export { x, x as y } case. Both names point at the same variable, so they get the same cell index, and the runtime will create one cell for both.

Parsing into a descriptor

The parser does not build a SourceTextModule directly. It builds a SourceTextModuleDescriptor, which is a zone-allocated structure (see The Zone Allocator: How V8 Cheats by Never Freeing) holding five maps and a vector:

cpp
// src/ast/modules.h:23
class SourceTextModuleDescriptor : public ZoneObject {
 public:
  explicit SourceTextModuleDescriptor(Zone* zone)
      : module_requests_(zone),
        special_exports_(zone),
        namespace_imports_(zone),
        regular_exports_(zone),
        regular_imports_(zone) {}

The five collections are:

  • module_requests_: the set of specifiers the module imports from, deduplicated by content so that import a from "x"; import b from "x" produces one request.
  • special_exports_: star exports (export * from "x") and indirect exports (export {a} from "x").
  • namespace_imports_: import * as ns from "x".
  • regular_exports_: local exports, export let x and export {x}.
  • regular_imports_: named imports, import {x} from "y".

The “regular” prefix is the spec’s word: a regular binding is one with a name, as opposed to a namespace binding (import * as ns).

Two interesting things happen at parse time.

First, the parser canonicalises indirect exports. Consider:

js
import { a as b } from "X";
export { b as c };

The export { b as c } is semantically the same as export { a as c } from "X". The parser rewrites it:

cpp
// src/ast/modules.h:248
  // Find any implicitly indirect exports and make them explicit.
  //
  // An explicitly indirect export is an export entry arising from an export
  // statement of the following form:
  //   export {a as c} from "X";
  // An implicitly indirect export corresponds to
  //   export {b as c};
  // in the presence of an import statement of the form
  //   import {a as b} from "X";
  // This function finds such implicitly indirect export entries and rewrites
  // them by filling in the import name and module request, as well as nulling
  // out the local name.  Effectively, it turns
  //   import {a as b} from "X"; export {b as c};
  // into:
  //   import {a as b} from "X"; export {a as c} from "X";
  // (The import entry is never deleted.)
  void MakeIndirectExportsExplicit(Zone* zone);

The rewrite moves the entry from regular_exports_ to special_exports_ and nulls out the local name:

cpp
// src/ast/modules.cc:246
void SourceTextModuleDescriptor::MakeIndirectExportsExplicit(Zone* zone) {
  for (auto it = regular_exports_.begin(); it != regular_exports_.end();) {
    Entry* entry = it->second;
    DCHECK_NOT_NULL(entry->local_name);
    auto ReExport = [&](const Entry* import_entry) {
      // Found an indirect export.  Patch export entry and move it from regular
      // to special.
      DCHECK_NULL(entry->import_name);
      DCHECK_LT(entry->module_request, 0);
      DCHECK_LE(0, import_entry->module_request);
      DCHECK_LT(import_entry->module_request,
                static_cast<int>(module_requests_.size()));
      entry->import_name = import_entry->import_name;
      entry->module_request = import_entry->module_request;
      // Hack: When the indirect export cannot be resolved, we want the error
      // message to point at the import statement, not at the export statement.
      // Therefore we overwrite [entry]'s location here.  Note that Validate()
      // has already checked for duplicate exports, so it's guaranteed that we
      // won't need to report any error pointing at the (now lost) export
      // location.
      entry->location = import_entry->location;
      entry->local_name = nullptr;
      AddSpecialExport(entry, zone);
      it = regular_exports_.erase(it);
    };

The “hack” comment is a nice detail: after the rewrite, the export entry no longer has a meaningful source position of its own, so the error message for an unresolvable export is pointed at the import statement instead. That is the position a human would look at.

Second, the parser decides whether the module has a top-level await. The trick is that a module body is compiled as a generator, with an initial yield at the top:

cpp
// src/parsing/parser.cc:816
    if (flags().is_module()) {
      DCHECK(flags().is_module());

      PrepareGeneratorVariables();
      Expression* initial_yield = BuildInitialYield(
          kNoSourcePosition, FunctionKind::kGeneratorFunction);
      body.Add(
          factory()->NewExpressionStatement(initial_yield, kNoSourcePosition));
      ParseModuleItemList(&body);
      // Modules will always have an initial yield. If there are any
      // additional suspends, they are awaits, and we treat the module as a
      // ModuleWithTopLevelAwait.
      if (function_state.suspend_count() > 1) {
        scope->set_module_has_toplevel_await();
      }

Compiling the module body as a generator function means the existing generator machinery handles the suspension points. A top-level await becomes a second suspend, and suspend_count() > 1 is how V8 knows the module is async. The flag is stored in the module’s flags bitfield:

cpp
// src/objects/source-text-module.tq:8
bitfield struct SourceTextModuleFlags extends uint31 {
  has_toplevel_await: bool: 1 bit;
  async_evaluation_ordinal: uint32: 30 bit;
}

From descriptor to heap objects

The descriptor is a parse-time structure. At instantiation it is serialised into a SourceTextModuleInfo, which is a FixedArray with five slots, and the module object itself is created from the SharedFunctionInfo:

cpp
// src/heap/factory.cc:3936
DirectHandle<SourceTextModule> Factory::NewSourceTextModule(
    DirectHandle<SharedFunctionInfo> sfi) {
  DirectHandle<SourceTextModuleInfo> module_info(
      sfi->scope_info()->ModuleDescriptorInfo(), isolate());
  DirectHandle<ObjectHashTable> exports =
      ObjectHashTable::New(isolate(), module_info->RegularExportCount());
  DirectHandle<FixedArray> regular_exports =
      NewFixedArray(module_info->RegularExportCount());
  const uint32_t regular_imports_len =
      module_info->regular_imports()->ulength().value();
  DirectHandle<FixedArray> regular_imports = NewFixedArray(regular_imports_len);

The tables are allocated empty. regular_exports has one slot per regular export, regular_imports one slot per regular import, and requested_modules one slot per module request. All three are parallel-indexed by the cell index, so slot i of regular_imports is the cell for the import with cell index -i-1:

cpp
// src/objects/source-text-module.cc:140
int SourceTextModule::ImportIndex(int cell_index) {
  DCHECK_EQ(SourceTextModuleDescriptor::GetCellIndexKind(cell_index),
            SourceTextModuleDescriptor::kImport);
  return -cell_index - 1;
}

Note the -1. Cell index -1 maps to slot 0. The offset is there because cell index 0 is reserved for “not a module variable”, so imports start at -1 and the array index has to be shifted.

The state machine

Before anything happens, a module is kUnlinked. It moves forward through a fixed set of states, and the transitions are checked at compile time:

cpp
// src/objects/module.h:72
  enum Status : int {
    kFirst,

    // Order matters!
    kUnlinked = kFirst,
    kPreLinking,
    kLinking,
    kLinked,
    kEvaluating,
    kEvaluatingAsync,
    kEvaluated,
    kEvaluatedError,

    kLast = kEvaluatedError
  };

with the legal edges spelled out:

cpp
// src/objects/module.h:99
  static constexpr bool IsValidTransition(Status from, Status to) {
    switch (from) {
      case kUnlinked:
        return to == kPreLinking;
      case kPreLinking:
        return to == kLinking || to == kLinked || to == kUnlinked;
      case kLinking:
        return to == kLinked || to == kUnlinked;
      case kLinked:
        return to == kEvaluating;
      case kEvaluating:
        return to == kEvaluatingAsync || to == kEvaluated ||
               to == kEvaluatedError;
      case kEvaluatingAsync:
        return to == kEvaluated || to == kEvaluatedError;
      case kEvaluated:
      case kEvaluatedError:
        return false;
    }
  }

V8 goes further than checking at runtime. There is a typestate handle Module::InState<ModuleT, kStates...> that only compiles when the module is in one of the listed states, so a function that requires a kLinking module cannot be handed a kEvaluating one:

cpp
// src/objects/module.h:125
  // Typestate handle proving at compile time that [handle] is in one
  // of [kStates...] and restricting state-dependent operations, transitions,
  // and fields to the states where they are valid. Obtained only by branching
  // via MatchState or as the result of a checked transition.
  template <typename ModuleT, Status... kStates>
  class InState;

and the fields are restricted with C++20 requires clauses:

cpp
// src/objects/module.h:385
  inline Tagged<SharedFunctionInfo> sfi() const
    requires(IsAllowed<kUnlinked, kPreLinking, kEvaluatedError>());
  inline Tagged<JSFunction> function() const
    requires(IsAllowed<kLinking>());
  inline Tagged<JSGeneratorObject> generator() const
    requires(IsAllowed<kLinked, kEvaluating, kEvaluatingAsync, kEvaluated>());

This is unusual for a JavaScript engine and it is a real quality win: the module lifecycle has a dozen states and the transition rules are non-trivial, and encoding them in the type system means a mistake is a compile error rather than a heisenbug.

Linking: the DFS

The linking phase has two halves.

PrepareInstantiate runs first and is purely about fetching the dependencies. It calls the embedder’s resolver callback once per module request and stores the resulting module objects in requested_modules:

cpp
// src/objects/source-text-module.cc:406
bool SourceTextModule::PrepareInstantiate(
    Isolate* isolate, DirectHandle<InState<kPreLinking>> module,
    v8::Local<v8::Context> context,
    const Module::UserResolveCallbacks& callbacks) {
  // One of the callbacks must be set, otherwise we cannot resolve.
  DCHECK_IMPLIES(callbacks.module_callback == nullptr,
                 callbacks.module_callback_by_index != nullptr);
  // Obtain requested modules.
  DirectHandle<SourceTextModuleInfo> module_info(module->info(), isolate);
  DirectHandle<FixedArray> module_requests(module_info->module_requests(),
                                           isolate);
  DirectHandle<FixedArray> requested_modules(module->requested_modules(),
                                             isolate);

This is where the embedder gets to say what “x” means. V8 does not know about file systems; the host does. The callback receives the specifier string and the import attributes and returns a module.

Then it recurses, so the whole graph is fetched before anything is resolved:

cpp
// src/objects/source-text-module.cc:486
  // Recurse.
  const uint32_t requested_modules_len = requested_modules->ulength().value();
  for (uint32_t i = 0; i < requested_modules_len; ++i) {
    DirectHandle<ModuleRequest> module_request(
        Cast<ModuleRequest>(module_requests->get(i)), isolate);
    if (module_request->phase() == ModuleImportPhase::kSource) {
      continue;
    }
    Handle<Module> requested_module(Cast<Module>(requested_modules->get(i)),
                                    isolate);
    if (!Module::PrepareInstantiate(isolate, requested_module, context,
                                    callbacks)) {
      return false;
    }
  }

Then it creates the export cells and the indirect export placeholders:

cpp
// src/objects/source-text-module.cc:502
  // Set up local exports.
  // TODO(neis): Create regular_exports array here instead of in factory method?
  for (uint32_t i = 0, n = module_info->RegularExportCount(); i < n; ++i) {
    int cell_index = module_info->RegularExportCellIndex(i);
    DirectHandle<FixedArray> export_names(
        module_info->RegularExportExportNames(i), isolate);
    CreateExport(isolate, module, cell_index, export_names);
  }

CreateExport is where a local export becomes a Cell:

cpp
// src/objects/source-text-module.cc:155
void SourceTextModule::CreateExport(Isolate* isolate,
                                    DirectHandle<InState<kPreLinking>> module,
                                    int cell_index,
                                    DirectHandle<FixedArray> names) {
  const uint32_t names_len = names->ulength().value();
  DCHECK_LT(0, names_len);
  DirectHandle<Cell> cell = isolate->factory()->NewCell();
  module->regular_exports()->set(ExportIndex(cell_index), *cell);

  Handle<ObjectHashTable> exports(module->exports(), isolate);
  for (uint32_t i = 0; i < names_len; ++i) {
    DirectHandle<String> name(Cast<String>(names->get(i)), isolate);
    DCHECK(IsTheHole(exports->Lookup(name)));
    exports = ObjectHashTable::Put(isolate, exports, name, cell);
  }
  module->set_exports(*exports);
}

One Cell, put into regular_exports at the slot for this cell index, and into the export hash table under every name it is exported under. That last part is what makes export { x, x as y } work: both names point at the same cell.

The indirect exports go in as placeholders - the export table gets the SourceTextModuleInfoEntry for now, and the entry is replaced with the real cell later:

cpp
// src/objects/source-text-module.cc:511
  // Partially set up indirect exports.
  // For each indirect export, we create the appropriate slot in the export
  // table and store its SourceTextModuleInfoEntry there.  When we later find
  // the correct Cell in the module that actually provides the value, we replace
  // the SourceTextModuleInfoEntry by that Cell (see ResolveExport).

FinishInstantiate is the real work. It is a recursive DFS:

cpp
// src/objects/source-text-module.cc:699
bool SourceTextModule::FinishInstantiate(
    Isolate* isolate, Handle<InState<kPreLinking>> pre_linking_module,
    ZoneForwardList<Handle<InState<kLinking>>>* stack, unsigned* dfs_index,
    Zone* zone, unsigned depth, unsigned* max_depth) {
  // Instantiate SharedFunctionInfo and mark module as instantiating for
  // the recursion.
  Handle<InState<kLinking>> module =
      TransitionToLinking(isolate, pre_linking_module, stack, dfs_index);

  // Recurse.
  DirectHandle<FixedArray> module_requests(module->info()->module_requests(),
                                           isolate);

TransitionToLinking pushes onto the DFS stack and assigns the DFS index:

cpp
// src/objects/source-text-module.cc:531
Handle<SourceTextModule::InState<Module::kLinking>>
SourceTextModule::TransitionToLinking(
    Isolate* isolate, Handle<InState<kPreLinking>> module,
    ZoneForwardList<Handle<InState<kLinking>>>* stack, unsigned* dfs_index) {
  DirectHandle<SharedFunctionInfo> shared(module->sfi(), isolate);
  DirectHandle<JSFunction> function =
      Factory::JSFunctionBuilder{isolate, shared, isolate->native_context()}
          .Build();
  module->set_code(*function);
  Handle<InState<kLinking>> linking = TransitionStatus<kLinking>(module);
  linking->set_dfs_index(*dfs_index);
  linking->set_dfs_ancestor_index(*dfs_index);
  (*dfs_index)++;
  stack->push_front(linking);
  return linking;
}

Two indices: dfs_index is a global counter (when was this module first visited) and dfs_ancestor_index is the index of the lowest ancestor still on the stack. A module is the root of a strongly connected component exactly when the two are equal:

cpp
// src/objects/source-text-module.cc:597
  // 13/16. If module.[[DFSAncestorIndex]] = module.[[DFSIndex]], then
  if (module->dfs_ancestor_index() == module->dfs_index()) {
    // This is the root of its strongly connected component.

This is the standard way to find SCC roots in a DFS, and it is how V8 detects cycles without a separate cycle-finding pass. When the DFS comes back from a child and the child’s ancestor index is lower than ours, the child is part of our SCC and we adopt its index:

cpp
// src/objects/source-text-module.cc:735
    MatchState(
        requested_module,
        [&](DirectHandle<Module::InState<Module, kLinking>> m) {
          // SyntheticModules go straight to kLinked so this must be a
          // SourceTextModule.
          module->UpdateDfsAncestorIndex(Cast<InState<kLinking>>(m));
        },

After the recursion, the module resolves its own imports and indirect exports:

cpp
// src/objects/source-text-module.cc:749
  // Resolve imports (InitializeEnvironment step 7) and indirect exports
  // (InitializeEnvironment step 1).
  //
  // Note: Environment and namespace creation (InitializeEnvironment steps 5-6
  // and 7.b-26) is performed by RunInitializationCode in
  // TransitionComponentToLinked once all imports and indirect exports in the
  // strongly connected component are resolved.
  DirectHandle<FixedArray> regular_imports(module_info->regular_imports(),
                                           isolate);
  const uint32_t regular_imports_len = regular_imports->ulength().value();
  for (uint32_t i = 0; i < regular_imports_len; ++i) {
    DirectHandle<SourceTextModuleInfoEntry> entry(
        Cast<SourceTextModuleInfoEntry>(regular_imports->get(i)), isolate);
    Handle<String> name(Cast<String>(entry->import_name()), isolate);
    MessageLocation loc(script, entry->beg_pos(), entry->end_pos());
    ResolveSet resolve_set(zone);
    DirectHandle<Cell> cell;
    if (!ResolveImport(isolate, module, name, entry->module_request(), loc,
                       true, &resolve_set)
             .ToHandle(&cell)) {
      return false;
    }
    module->regular_imports()->set(ImportIndex(entry->cell_index()), *cell);
  }

That last line is the whole live-binding mechanism in one statement. The cell that ResolveImport returned - the exporter’s cell - is stored into the importer’s regular_imports table. Two modules, one cell.

ResolveImport in turn calls ResolveExport on the target module, and ResolveExport is where indirect exports and star exports get untangled:

cpp
// src/objects/source-text-module.cc:210
MaybeHandle<Cell> SourceTextModule::ResolveExport(
    Isolate* isolate,
    Handle<InState<kPreLinking, kLinking, kLinked, kEvaluatingAsync, kEvaluated,
                   kEvaluatedError>>
        module,
    DirectHandle<String> module_specifier, Handle<String> export_name,
    MessageLocation loc, bool must_resolve, Module::ResolveSet* resolve_set) {
  DCHECK(!export_name.is_null());

  Handle<Object> object(module->exports()->Lookup(export_name), isolate);
  if (IsCell(*object)) {
    // Already resolved (e.g. because it's a local export).
    return Cast<Cell>(object);
  }

If the export table already has a cell for that name, we are done - that is the local export case, and it is why the same cell is shared.

If the table has a hole, the export is indirect or a star export, and the real cell lives in another module. The recursion happens here, and it needs cycle detection - resolving a -> b -> a would otherwise loop forever:

cpp
// src/objects/source-text-module.cc:225
  // Check for cycle before recursing.
  {
    // Attempt insertion with a null string set.
    auto result = resolve_set->insert({module, nullptr});
    UnorderedStringSet*& name_set = result.first->second;
    if (result.second) {
      // |module| wasn't in the map previously, so allocate a new name set.
      Zone* zone = resolve_set->zone();
      name_set = zone->New<UnorderedStringSet>(zone);
    } else if (name_set->count(export_name)) {
      // Cycle detected.
      if (must_resolve) {
        isolate->ThrowAt(isolate->factory()->NewSyntaxError(
                             MessageTemplate::kCyclicModuleDependency,
                             export_name, module_specifier),
                         &loc);
        return MaybeHandle<Cell>();
      }
      return MaybeHandle<Cell>();
    }
    name_set->insert(export_name);
  }

The ResolveSet is a map from module to the set of export names currently being resolved for that module. A cycle is “I am already resolving this exact name in this exact module”. Note that the set is per-module, not global: resolving a.x and b.x at the same time is fine, but resolving a.x while resolving a.x is not.

And when the indirect export finally resolves, the placeholder is replaced with the real cell:

cpp
// src/objects/source-text-module.cc:274
  // The export table may have changed but the entry in question should be
  // unchanged.
  Handle<ObjectHashTable> exports(linking_module->exports(), isolate);
  DCHECK(IsSourceTextModuleInfoEntry(exports->Lookup(export_name)));

  exports = ObjectHashTable::Put(isolate, exports, export_name, cell);
  linking_module->set_exports(*exports);
  return cell;

Star exports (export * from "x") are handled separately, because they export many names and the set of names is only known once the target module’s own exports are known. FetchStarExports walks the star targets and merges their export tables, with a rule that default is never re-exported:

cpp
// src/objects/source-text-module.cc:859
      if (name->Equals(roots.default_string())) continue;
      if (!IsTheHole(exports->Lookup(name))) continue;

and if two star exports provide the same name with different cells, the name is marked ambiguous and becomes inaccessible:

cpp
// src/objects/source-text-module.cc:863
      auto insert_result = more_exports.insert(std::make_pair(name, cell));
      if (!insert_result.second) {
        auto it = insert_result.first;
        if (*it->second == *cell || IsUndefined(*it->second)) {
          // We already recorded this mapping before, or the name is already
          // known to be ambiguous.  In either case, there's nothing to do.
        } else {
          DCHECK(IsCell(*it->second));
          // Different star exports provide different cells for this name, hence
          // mark the name as ambiguous.
          it->second = isolate->factory()->undefined_value();
        }
      }

undefined as the ambiguity marker is a nice trick: the export table maps names to cells, and undefined is not a cell, so the ambiguity is represented by an out-of-band value in a table whose value type is Cell.

The namespace object

import * as ns from "m" gives you a namespace object: a special exotic object whose properties are the module’s exports, and are live.

The namespace is built lazily and cached in a cell:

cpp
// src/objects/module.cc:485
Handle<Cell> Module::GetModuleNamespaceCell(Isolate* isolate,
                                            Handle<Module> module,
                                            ModuleImportPhase phase) {
  ReadOnlyRoots roots(isolate);

  DCHECK(phase == ModuleImportPhase::kEvaluation ||
         phase == ModuleImportPhase::kDefer);

  Tagged<Object> maybe_cell = phase == ModuleImportPhase::kEvaluation
                                  ? module->module_namespace()
                                  : module->deferred_module_namespace();
  if (!IsUndefined(maybe_cell)) {
    return handle(Cast<Cell>(maybe_cell), isolate);
  }
  Handle<Cell> cell = isolate->factory()->NewCell();

The cell is used as a cache slot so that two importers of the same namespace get the same object. That matters: import * as a from "m" and import * as b from "m" must give a === b.

The namespace’s properties are accessors, not data properties. Each export name gets an accessor that goes back to the module’s export table and reads the cell:

cpp
// src/objects/module.cc:567
  // Create the properties in the namespace object. Transition the object
  // to dictionary mode so that property addition is faster.
  PropertyAttributes attr = DONT_DELETE;
  JSObject::NormalizeProperties(isolate, ns, CLEAR_INOBJECT_PROPERTIES,
                                static_cast<int>(names.size()),
                                "JSModuleNamespace");
  JSObject::NormalizeElements(isolate, ns);
  DirectHandle<AccessorInfo> module_namespace_property_accessor =
      isolate->factory()->module_namespace_property_accessor();
  for (const auto& name : names) {
    uint32_t index = 0;
    if (name->AsArrayIndex(&index)) {
      JSObject::SetNormalizedElement(
          ns, index, module_namespace_property_accessor,
          PropertyDetails(PropertyKind::kAccessor, attr,
                          PropertyCellType::kMutable));
    } else {
      JSObject::SetNormalizedProperty(
          ns, name, module_namespace_property_accessor,
          PropertyDetails(PropertyKind::kAccessor, attr,
                          PropertyCellType::kMutable))
          .Check();
    }
  }
  JSObject::PreventExtensions(isolate, ns, kThrowOnError).ToChecked();

Every property is an accessor, so reading ns.x goes through ModuleNamespaceEntryGetter:

cpp
// src/builtins/accessors.cc:244
void Accessors::ModuleNamespaceEntryGetter(
    v8::Local<v8::Name> name, const v8::PropertyCallbackInfo<v8::Value>& info) {
  i::Isolate* isolate = reinterpret_cast<i::Isolate>(info.GetIsolate());
  HandleScope scope(isolate);
  Tagged<JSModuleNamespace> holder =
      Cast<JSModuleNamespace>(*Utils::OpenDirectHandle(*info.Holder()));
  DirectHandle<Object> result;
  if (holder->GetExport(isolate, Cast<String>(Utils::OpenDirectHandle(*name)))
          .ToHandle(&result)) {
    info.GetReturnValue().Set(Utils::ToLocal(result));
  }
}

and GetExport reads the cell, with a temporal-dead-zone check:

cpp
// src/objects/module.cc:610
MaybeDirectHandle<Object> JSModuleNamespace::GetExport(
    Isolate* isolate, DirectHandle<String> name) {
  DirectHandle<Object> object(module()->exports()->Lookup(name), isolate);
  if (IsTheHole(*object)) {
    return isolate->factory()->undefined_value();
  }

  DirectHandle<Object> value(Cast<Cell>(*object)->value(), isolate);
  if (IsTdzHole(*value)) {
    // According to https://tc39.es/ecma262/#sec-InnerModuleLinking
    // step 10 and
    // https://tc39.es/ecma262/#sec-source-text-module-record-initialize-environment
    // step 8-25, variables must be declared in Link. And according to
    // https://tc39.es/ecma262/#sec-module-namespace-exotic-objects-get-p-receiver,
    // here accessing uninitialized variable error should be thrown.
    THROW_NEW_ERROR(isolate,
                    NewReferenceError(
                        MessageTemplate::kAccessedUninitializedVariable, name));
  }

  return value;
}

The TDZ hole is a third state a cell can be in: uninitialised. That handles the import { x } from "m" where m has a circular dependency on the importer - the cell exists but has no value yet, and touching it is a ReferenceError.

The namespace is also made non-extensible and optimised as a prototype, with a comment that gives both reasons:

cpp
// src/objects/module.cc:593
  // Optimize the namespace object as a prototype, for two reasons:
  // - The object's map is guaranteed not to be shared. ICs rely on this.
  // - We can store a pointer from the map back to the namespace object.
  //   Turbofan can use this for inlining the access.
  JSObject::OptimizeAsPrototype(ns);

Reading and writing at runtime

The bytecode that accesses a module variable is LdaModuleVariable and StaModuleVariable. The implementation is where the cell indirection becomes visible:

cpp
// src/interpreter/interpreter-generator.cc:870
// LdaModuleVariable <cell_index> <depth>
//
// Load the contents of a module variable into the accumulator.  The variable is
// identified by <cell_index>.  <depth> is the depth of the current context
// relative to the module context.
IGNITION_HANDLER(LdaModuleVariable, InterpreterAssembler) {
  TNode<IntPtrT> cell_index = BytecodeOperandImmIntPtr(0);
  TNode<Uint32T> depth = BytecodeOperandUImm(1);

  TNode<Context> module_context = GetContextAtDepth(GetContext(), depth);
  TNode<SourceTextModule> module =
      CAST(LoadContextElementNoCell(module_context, Context::EXTENSION_INDEX));

  Label if_export(this), if_import(this), end(this);
  Branch(IntPtrGreaterThan(cell_index, IntPtrConstant(0)), &if_export,
         &if_import);

  BIND(&if_export);
  {
    TNode<FixedArray> regular_exports = LoadObjectField<FixedArray>(
        module, offsetof(SourceTextModule, regular_exports_));
    // The actual array index is (cell_index - 1).
    TNode<IntPtrT> export_index = IntPtrSub(cell_index, IntPtrConstant(1));
    TNode<Cell> cell =
        CAST(LoadFixedArrayElement(regular_exports, export_index));
    SetAccumulator(LoadObjectField(cell, offsetof(Cell, maybe_value_)));
    Goto(&end);
  }

Positive cell index means export, negative means import, and the depth operand says how far up the context chain the module context is. The module object is reached through the context’s extension slot.

The store side is more restricted:

cpp
// src/interpreter/interpreter-generator.cc:944
  BIND(&if_import);
  {
    // Not supported (probably never).
    Abort(AbortReason::kUnsupportedModuleOperation);
    Goto(&end);
  }

You cannot assign to an import. That is the spec rule - imports are read-only bindings - and V8 enforces it at the bytecode level rather than with a runtime check. The comment “probably never” is honest: the compiler should never generate a store to an import, so this is a belt-and-braces abort.

TurboFan does the same thing with a different mechanism, and it has a nice optimisation: if the module is a compile-time constant, the cell itself is a constant:

cpp
// src/compiler/js-typed-lowering.cc:1794
  if (module_type.IsHeapConstant()) {
    SourceTextModuleRef module_constant =
        module_type.AsHeapConstant()->Ref().AsSourceTextModule();
    OptionalCellRef cell_constant =
        module_constant.GetCell(broker(), cell_index);
    if (cell_constant.has_value()) {
      return jsgraph()->ConstantNoHole(*cell_constant, broker());
    }
  }

The cell is a constant, but the value inside the cell is not - and that is exactly right, because the cell is mutable. The import is constant; the binding it points at is not.

Evaluation: DFS post-order

Evaluation is the same shape as linking: a recursive DFS that runs each module’s body after its dependencies. InnerModuleEvaluation is the heart of it:

cpp
// src/objects/source-text-module.cc:1368
bool SourceTextModule::InnerModuleEvaluation(
    Isolate* isolate, Handle<SourceTextModule> module,
    ZoneForwardList<Handle<InState<kEvaluating>>>* stack, unsigned* dfs_index) {
  // InnerModuleEvaluation(module, stack, index)
  // The spec returns the module index for proper numbering of dependencies;
  // we pass it by pointer instead and return a bool for success/failure.
  return MatchState(
      module,

The state dispatch is the interesting bit. MatchState is a variadic dispatcher over the module’s status, and the handlers are typed so that each one only compiles for the states it handles:

cpp
// src/objects/source-text-module.cc:1376
      [](DirectHandle<InState<kEvaluatingAsync, kEvaluating, kEvaluated>>) {
        // 2. If module.[[Status]] is either EVALUATING-ASYNC or EVALUATED, then
        //   a. If module.[[EvaluationError]] is undefined, return index.
        // 3. If module.[[Status]] is EVALUATING, return index.
        return true;
      },

Step 2 is the cycle case: if the module is already kEvaluating, it is on the DFS stack, and we return immediately without re-running it. That is what makes a cycle terminate, and it is why the evaluation order within a cycle is “whoever got there first”.

The recursion into dependencies is the post-order:

cpp
// src/objects/source-text-module.cc:1447
        // 11. For each ModuleRequest Record required of
        //     module.[[RequestedModules]],
        for (size_t i = 0, length = evaluation_list.size(); i < length; ++i) {
          // b. If requiredModule.[[Phase]] is evaluation, then
          Handle<Module> requested_module = evaluation_list[i];
          // c. If requiredModule is a Cyclic Module Record, then
          if (Handle<SourceTextModule> required_source_module;
              TryCast(requested_module, &required_source_module)) {
            // b. Set index to ? InnerModuleEvaluation(requiredModule, stack,
            //                                         index).
            // (Out of order because InnerModuleEvaluation is type-driven.)
            if (!InnerModuleEvaluation(isolate, required_source_module, stack,
                                       dfs_index)) {
              return false;
            }

The recursion happens before the module’s own body runs, so dependencies run first. That is the post-order.

There is one wrinkle: the order of the dependency list. V8 does not simply use requested_modules in order. It deduplicates and handles the defer phase specially:

cpp
// src/objects/source-text-module.cc:1415
        Zone zone(isolate->allocator(), ZONE_NAME);
        // There's an evaluation set to perform optimized check if a module is
        // already in evaluation_list. It's necessary to keep evaluation order
        // as it's seen to be spec compliant.
        UnorderedModuleSet evaluation_set(&zone);
        ZoneVector<Handle<Module>> evaluation_list(&zone);
        UnorderedModuleSet seen_modules(&zone);

The evaluation_set is a hash set, but the list preserves the spec’s order. This matters because the spec’s evaluation order is observable: if a.js and b.js both import c.js and both have side effects, the order in which a and b run is specified.

The module’s own body then runs, either synchronously or as an async module:

cpp
// src/objects/source-text-module.cc:1549
        // 12. If module.[[PendingAsyncDependencies]] > 0 or module.[[HasTLA]]
        //     is true, then
        if (evaluating_module->HasPendingAsyncDependencies() ||
            evaluating_module->has_toplevel_await()) {
          // a. Assert: module.[[AsyncEvaluation]] is false and was never
          //    previously set to true.
          DCHECK_EQ(evaluating_module->async_evaluation_ordinal(),
                    kNotAsyncEvaluated);
          // b. Set module.[[AsyncEvaluation]] to true.
          // c. NOTE: The order in which module records have their
          //    [[AsyncEvaluation]] fields transition to true is significant.
          evaluating_module->set_async_evaluation_ordinal(
              isolate->NextModuleAsyncEvaluationOrdinal());

          // d. If module.[[PendingAsyncDependencies]] = 0, perform
          //    ExecuteAsyncModule(module).
          // The execution may have been terminated and can not be resumed, so
          // just raise the exception.
          if (!evaluating_module->HasPendingAsyncDependencies()) {
            MAYBE_RETURN(SourceTextModule::ExecuteAsyncModule(
                             isolate, evaluating_module),
                         false);
          }
        } else {  // 13. Else,
          // a. Perform ? module.ExecuteModule().

The “NOTE: The order in which module records have their [[AsyncEvaluation]] fields transition to true is significant” is the whole async-module scheduling mechanism in one comment. The ordinal assigned here is a global monotonically increasing counter, and it is used later to decide which async parent modules can resume, in what order.

Async modules and the top-level capability

An async module’s Evaluate returns a promise, and the module’s “top level capability” is that promise:

cpp
// src/objects/source-text-module.cc:1010
MaybeDirectHandle<JSPromise> SourceTextModule::Evaluate(
    Isolate* isolate, Handle<InState<kLinked>> module) {
  DCHECK(IsUndefined(module->top_level_capability()));

  // 5. Let stack be a new empty List.
  Zone zone(isolate->allocator(), ZONE_NAME);
  ZoneForwardList<Handle<InState<kEvaluating>>> stack(&zone);
  unsigned dfs_index = 0;

  // 6. Let capability be ! NewPromiseCapability(%Promise%).
  DirectHandle<JSPromise> capability = isolate->factory()->NewJSPromise();

  // 7. Set module.[[TopLevelCapability]] to capability.
  module->set_top_level_capability(*capability);

The Zone here is the DFS stack’s backing store - it is exactly the “allocate a lot, throw it away” pattern the zone allocator is for.

The failure path is where the module state machine earns its keep. If evaluation throws, every module on the DFS stack has to be marked as errored, not just the one that threw:

cpp
// src/objects/source-text-module.cc:1030
  if (!InnerModuleEvaluation(isolate, module, &stack, &dfs_index)) {
    DirectHandle<Object> exception(isolate->exception(), isolate);
    // a. For each Cyclic Module Record m in stack, do
    DirectHandle<InState<kEvaluatedError>> errored_module;
    for (DirectHandle<InState<kEvaluating>> m : stack) {
      //   i. Assert: m.[[Status]] is EVALUATING.
      DCHECK(m->status() == kEvaluating);
      //  ii. Set m.[[Status]] to EVALUATED.
      // iii. Set m.[[EvaluationError]] to result.
      const bool is_root = *m == *module;
      DirectHandle<InState<kEvaluatedError>> errored =
          RecordError(isolate, m, exception);
      if (is_root) errored_module = errored;
    }

and then the promise is rejected - except in the termination case, where V8 deliberately does not reject it:

cpp
// src/objects/source-text-module.cc:1048
    // A termination exception cannot reject the capability: that would run
    // embedder code (see Isolate::ReportPromiseReject). Drop the capability
    // instead; Module::Evaluate materializes a rejected one for an errored
    // module.
    if (isolate->is_execution_terminating()) {

The reasoning is subtle and worth spelling out: rejecting a promise runs embedder code (the unhandled-rejection hook), and during termination you must not run embedder code. So the capability is dropped and a fresh rejected one is created later if someone asks.

Trade-offs

What it buys.

  • Live bindings for free. Because imports and exports are the same Cell object, “live” is not a special case in the runtime - it is the default. A write to an export is a write to a cell, and every importer sees it.
  • Correct cycles. The DFS ancestor index finds SCC roots without a separate graph algorithm, and the same mechanism gives the spec’s evaluation order.
  • Compile-time state checking. The InState typestate means the dozen-state lifecycle is checked by the compiler, not by convention.

What it costs.

  • Everything is a heap object. Every module binding is a Cell allocation. A module with a thousand exports has a thousand cells, and the export table is an ObjectHashTable on top.
  • Namespace objects are accessor-based. Every property read on a namespace goes through a C++ accessor that does a hash lookup in the export table and a cell dereference. That is much slower than reading a data property, and it is the price of live bindings.
  • The async machinery is intricate. The async_evaluation_ordinal, the pending async dependency counters, the async parent module lists, and the AvailableAncestorsSet ordering together implement a scheduling problem that is genuinely hard.
  • Two tables and a hash table per module. regular_exports and regular_imports are parallel arrays, exports is a hash table, and requested_modules is another array. The bookkeeping is not free.

The bottom line

The mental model is: a module is three parallel arrays and a hash table, and a binding is a Cell that two modules can share.

regular_exports[i] is the cell for the i-th local export. regular_imports[i] is the cell for the i-th regular import - and for an import, that cell is the same object as the exporter’s cell. exports is a hash table from name to cell, so a namespace object can look up a name and get the live value.

Everything else follows from that. The DFS is there to find the right order to initialise things in. The SCC detection is there to make cycles terminate. The TDZ hole is there because a cell can exist before its value does. And the accessor-based namespace is there because the value behind a name can change at any time.

The thing people get wrong

The belief is that import { x } from "m" copies x into the importing module, so reassigning x in the importer is local and reassigning it in the exporter is invisible. Both halves of that are wrong.

The import is a reference to a cell, so m’s later writes are visible. And the importer cannot write to the binding at all - assigning to an import is a syntax error, and at the bytecode level it is an AbortReason::kUnsupportedModuleOperation rather than a runtime check. The only way to change an imported binding is to call an exported function that changes it, or to mutate an exported object.

The second misconception is that a module namespace is a snapshot of the module’s exports taken at link time. It is not a snapshot at all: every property on it is an accessor that reads through to the cell, which is why Object.keys(ns) is stable but ns.x tracks the live value.

V8InternalsModulesParserSpec