# Understanding the Module System and Import Resolution in workerd

> Master workerd's module system and import resolution. Learn how workerd handles static, dynamic, and source-phase imports for CommonJS, JSON, Wasm, and data files.

- Repository: [Cloudflare/workerd](https://github.com/cloudflare/workerd)
- Tags: internals
- Published: 2026-03-18

---

**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`](https://github.com/cloudflare/workerd/blob/main/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 `await`s 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

```javascript
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

```javascript
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

```javascript
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

```javascript
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`](https://github.com/cloudflare/workerd/blob/main/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.