Understanding the Module System and Import Resolution in workerd

The workerd runtime implements a comprehensive ES module loader on top of V8, using a custom IsolateModuleRegistry to resolve static imports, dynamic imports, and source-phase imports while supporting synthetic modules for CommonJS, JSON, Wasm, and data files.

The module system in cloudflare/workerd bridges V8's JavaScript engine with Cloudflare's worker runtime, handling everything from bare specifier resolution to Node.js compatibility layers. This article examines the implementation details found in src/workerd/jsg/modules-new.c++ and related headers to explain how the registry resolves specifiers, caches module descriptors, and evaluates different module types.

Architecture of the workerd Module System

The module system centers on a registry pattern that abstracts V8's module callbacks into typed C++ classes using Cap'n Proto schemas and KJ containers for thread-safe operations.

Core Abstractions

At the base of the hierarchy sits the Module abstract class defined in src/workerd/jsg/modules.h. This class provides the interface for getDescriptor, evaluate, and flag handling across all module types. Two primary implementations exist:

  • EsModule – Handles standard ECMAScript modules by compiling source code using V8's ScriptCompiler, optionally leveraging code caching for performance.
  • SyntheticModule – Manually constructs module namespaces for non-ESM formats including CommonJS, JSON, Wasm, and raw data files.

Both implementations reside in src/workerd/jsg/modules-new.c++, where SyntheticModule::evaluationSteps forwards evaluation to the original module's callback after registry lookup.

Registry and Bundling

The IsolateModuleRegistry ties the system together as a per-V8-Isolate registry that caches descriptors and implements the resolution logic. It maintains a three-index lookup cache using kj::Table keyed by V8 module handles, normalized specifiers, and URLs.

ModuleBundle represents collections of modules that can resolve specifiers. Bundles may be static, fallback, or builtin, and they handle aliasing through StaticModuleBundle::aliases or FallbackModuleBundle::aliases. When the registry encounters an alias, it caches the resolution so subsequent lookups bypass the bundle entirely.

Resolution Context

The ResolveContext structure, defined in src/workerd/jsg/modules.capnp, describes how a specifier should be resolved. It tracks the resolution type (bundle vs builtin vs builtin-only), source information, referrer URL, and the raw specifier string. This Cap'n Proto schema drives the lookup logic and determines flag handling throughout the resolution pipeline.

How Import Resolution Works in workerd

The resolution flow differs between static imports (parsed at compile time), dynamic imports (runtime), and the legacy synchronous require used for CommonJS compatibility.

Static Import Resolution

When V8 encounters import … from "specifier", it invokes the host callback resolveModuleCallback<false> (the template parameter false disables source-phase handling). The resolution follows this sequence:

  1. Specifier conversion – The V8 v8::String converts to kj::String via specifierToString.
  2. Referrer lookup – The registry calls lookup(js, referrer) to obtain the referrer URL. If the referrer isn't a known module, the bundle base URL serves as the fallback.
  3. Context building – The resolve context type defaults to BUNDLE, switching to BUILTIN_ONLY if the referrer is a builtin module.
  4. Node.js compatibility – When Node.js compat is enabled, checkNodeSpecifier rewrites bare specifiers like "fs" to node: URLs. Special handling redirects node:process based on the enable_nodejs_process_v2 flag.
  5. URL resolution – The referrer URL resolves the specifier via referrer.tryResolve, applying NORMALIZE_PATH to remove ./.. segments and percent-encode paths.
  6. Registry lookup – IsolateModuleRegistry::resolve checks the lookupCache first. On cache miss, it calls inner.lookup(innerContext) on the underlying ModuleBundle.
  7. Descriptor compilation – The bundle returns either a kj::Own<Module> instance or an alias. The registry caches the result keyed by the original (un-normalized) specifier to preserve query strings and fragments. The EsModule::getDescriptor method compiles the source and returns a v8::Module handle to V8, which then instantiates it via module->Instantiate.

Dynamic Import Resolution

Dynamic imports (await import("specifier")) use the same resolution logic but trigger dynamicImportModuleCallback instead. Key differences include:

  • Import attributes – Currently rejected; if import_attributes are present, the callback returns a rejected promise with TypeError.
  • Error handling – All synchronous V8 calls wrap in js.tryCatch, converting JsException into rejected promises for the JavaScript side.

Source-Phase Imports

For import … with {phase: "source"}, the SourcePhase flag passes as YES to dynamicImportWithPhase. The registry's dynamicResolve returns a Promise that resolves to either a module instance or, exclusively for Wasm modules, the uninstantiated WebAssembly.Module object extracted from the synthetic module's namespace.

Synchronous require for CommonJS

The IsolateModuleRegistry::require method provides synchronous CommonJS support:

  • It checks the same lookup cache used by ESM imports.
  • If the module is already evaluated or currently evaluating, it returns the namespace directly.
  • For new modules, it forces evaluation via module.evaluate and runs microtasks to settle any top-level awaits that resolved synchronously.
  • Top-level await that does not settle immediately triggers kTopLevelAwaitError with the message "Use of top-level await in a synchronously required module".

Synthetic Modules and Non-ESM Support

workerd supports non-JavaScript modules through the SyntheticModule class, created via ModuleBundle::Builder::add with a ModuleBundle::Builder::ResolveCallback.

CommonJS Resolution

When resolving a CommonJS module, the registry constructs a SyntheticModule that populates the namespace manually. The require function (exposed to ESM contexts) ultimately calls IsolateModuleRegistry::require, which evaluates the module synchronously and returns the namespace object.

JSON and Wasm Modules

  • JSON modules – Module::newJsonModuleHandler builds a synthetic module with a default export containing the parsed JSON value.
  • Wasm modules – Module::newWasmModuleHandler creates a synthetic module whose default export is a WebAssembly.Module object. This enables source-phase imports to extract the module without instantiation, supporting advanced use cases like compiling Wasm once and instantiating multiple times.

Caching and Performance Optimization

The module system implements aggressive caching at three levels to ensure fast repeat imports and correct handling of URL fragments:

  1. V8 descriptor cache – V8's internal module cache prevents recompilation of already-loaded modules.
  2. Registry lookup cache – The lookupCache in IsolateModuleRegistry uses kj::Table with three indices: EntryCallbacks (V8 handle), ContextCallbacks (normalized specifier), and UrlCallbacks (full URL).
  3. Bundle-level caching – Module bundles cache resolved instances and aliases, with the registry storing aliases to bypass bundle lookup on subsequent requests.

The cache retains the original URL including query parameters and fragments as part of the key, allowing the same logical file to be imported with different URL fragments without triggering duplicate compilation.

Practical Code Examples

Static import inside a worker script

import { foo } from "./utils.js";
console.log(foo);

V8 invokes resolveModuleCallback<false> → IsolateModuleRegistry::resolve. The specifier "./utils.js" resolves relative to the current module's URL, normalizes through NORMALIZE_PATH, looks up in the bundle, compiles if needed, caches in lookupCache, and instantiates via V8.

Dynamic import with error handling

try {
  const mod = await import("./optional.js");
  mod.run();
} catch (e) {
  console.error("Failed to load optional.js:", e);
}

The dynamicImport callback resolves the specifier as above, but delivers the result as a Promise. If the module does not exist, the promise rejects with Error: Module not found: ….

Source-phase import of a Wasm module

const wasmMod = await import("./my.wasm", { with: { type: "module" }, phase: "source" });
console.log(wasmMod.default); // → WebAssembly.Module object

With phase: "source" set to YES, dynamicImportWithPhase returns a promise resolving to the WebAssembly.Module object directly, skipping instantiation. This currently supports only Wasm modules.

Using require for a CommonJS library

const lib = require("./cjs-lib.js");
console.log(lib.version);

require synchronously resolves the module (using the same resolution as static imports) and forces evaluation via module.evaluate. If the module contains top-level await that does not settle immediately, the runtime throws "Use of top-level await in a synchronously required module …".

Summary

  • workerd implements a full ESM loader on top of V8 using IsolateModuleRegistry in src/workerd/jsg/modules-new.c++ to manage built-in, bundle-provided, and fallback modules.
  • The system differentiates static, dynamic, and source-phase imports through dedicated V8 callbacks (resolveModuleCallback, dynamicImportModuleCallback, and dynamicImportWithPhase).
  • Synthetic modules enable CommonJS (require), JSON, Wasm, and data file support, all resolved via the same ResolveContext machinery defined in src/workerd/jsg/modules.capnp.
  • Caching occurs at three levels: V8 descriptor cache, registry lookup cache (kj::Table), and per-bundle module cache, preserving URL fragments and query strings.
  • Node.js compatibility rewrites bare specifiers to node: URLs via checkNodeSpecifier, with special handling for node:process based on feature flags.

Frequently Asked Questions

How does workerd handle circular module dependencies?

The IsolateModuleRegistry handles circular dependencies through its evaluation state tracking. When a module is first imported, it enters an "evaluating" state in the lookup cache. If another module imports it during this phase, the registry detects the existing entry and returns the pending namespace. For ESM, V8's module->Instantiate handles the circular graph construction, while for CommonJS, the registry caches the module object before evaluation completes, allowing synchronous require to return the partially initialized exports object.

What is the difference between static and dynamic import resolution in workerd?

Static imports (import … from) resolve during the instantiation phase via resolveModuleCallback<false>, which immediately returns a v8::Module descriptor to V8. Dynamic imports (await import()) use dynamicImportModuleCallback, which returns a Promise that resolves after the same lookup and compilation process. The key distinction is that dynamic imports currently reject if import attributes are present, and they support source-phase imports (returning uninstantiated Wasm modules) through the dynamicImportWithPhase callback, whereas static imports do not support source-phase resolution.

How does workerd support Node.js compatibility for module resolution?

When Node.js compatibility is enabled, the resolution pipeline in IsolateModuleRegistry::resolve calls checkNodeSpecifier (defined in src/workerd/api/node/module.h) to rewrite bare specifiers like "fs" or "path" to node: URLs. The system also implements special redirects, such as mapping node:process to internal builtins based on the enable_nodejs_process_v2 flag. This allows npm packages using CommonJS require or ESM import to resolve Node.js built-in modules correctly within the workerd runtime.

Can I use import attributes with dynamic imports in workerd?

No, as implemented in src/workerd/jsg/modules-new.c++, the dynamicImportModuleCallback explicitly checks for the presence of import_attributes and returns a rejected promise with a TypeError if any attributes are detected. This means assertions like import("./foo.json", { assert { type: "json" } }) will fail. However, you can import JSON modules as synthetic modules without attributes if they are pre-configured in the module bundle, or use standard ESM imports for files with recognized extensions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →