Inside the new module registry
The module registry is the part of workerd, the open-source runtime that powers Cloudflare Workers, that turns module specifiers into runnable code. Once a specifier is resolved, the registry compiles it and hands V8 a module object it can link and execute. Every Worker deployment passes through this system, whether the code arrives as a single bundled file or as a full graph of separate ESM, CommonJS, and WebAssembly modules.
We've rewritten this registry to be faster, more standards-compliant, and more closely aligned with how Node.js resolves and loads modules. The new implementation is available now behind the new_module_registry compatibility flag.
Why the old registry needed replacing
The original registry resolved specifiers as filesystem-style paths rather than URLs. That distinction seems minor, but it closed off a remarkable amount of behavior: there was no clean way to implement import.meta.url; relative imports didn't follow the same resolution rules as new URL(); and protocols like node: and cloudflare: were handled as special-cased string prefixes.
The old registry also compiled the entire Worker bundle up front, whether or not a given module was ever imported, and it kept a separate private copy of everything per V8 isolate. Because Cloudflare runs multiple V8 isolate replicas of the same Worker to spread load across CPU cores, that meant compiling the same source repeatedly and holding multiple copies in memory.
None of this was a bug, but it made incremental evolution difficult. The new registry treats URLs as the fundamental specifier format and designs for laziness and cache sharing from day one. The existing implementation remains in place; already-deployed Workers will keep working exactly as they have.
import.meta is fully supported
With the flag enabled, import.meta.url, import.meta.main, and import.meta.resolve() all work. The first two provide basic information about the module: import.meta.url is the module's URL, and import.meta.main is true only for the module configured as the Worker's entrypoint.
import.meta.resolve() is a pure string transform that resolves a specifier against the current module without importing it. It behaves like Node.js and browsers: it doesn't verify that the resolved URL points to a real module, and it throws a TypeError for a specifier that can't be parsed as a URL rather than returning null. It also normalizes percent-encoding the same way new URL() does, which means paths like ./a/../b.js are collapsed, but characters already percent-encoded are not decoded. For example, import.meta.resolve('%66oo.js') resolves to file:///bundle/%66oo.js, not file:///bundle/foo.js.
Specifiers are real URLs
Relative imports now resolve exactly as new URL(specifier, base) does, because that's what happens internally. Full URLs also work as specifiers, not just relative paths.
More interesting is what happens with query strings and fragments. Per the module-identity rules browsers use, a specifier with a different query string or fragment is a genuinely distinct module instance, even if it points at the same underlying source. A module imported as ./counter.js?a and ./counter.js?b is evaluated twice, each with its own import.meta.url and its own copy of top-level state. Importing the same specifier with the same query string again still returns the same instance.
Import attributes are validated
The original registry silently ignored import attributes, which violates the spec. Implementations are expected to throw an exception when they encounter an import attribute they don't understand.
json is the only attribute type enabled today, since it's the only relevant TC39 proposal to reach Stage 4. text and bytes are recognized because they track the Import Text and Import Bytes proposals, but they're rejected with specific errors instead of being silently ignored. Any attribute key other than type is now a hard error, and specifying a type that doesn't match the module's actual content also throws.
require(esm) follows Node.js
When you require() something that turns out to be an ES module, either directly in CommonJS or through require('node:module').createRequire(), the registry follows Node.js require(esm) behavior:
- If the module has a string-named export called
'module.exports', which is Node.js' mechanism for letting an ES module control whatrequire()sees, that value is returned. - Otherwise,
require()returns the module's namespace object. - The exception is
workerd's ownnode:built-ins. They're ES modules that wrap a CommonJS-style API in a default export, so requiring one returns the default export directly.require('node:buffer').Bufferbehaves as expected.
If the module being required, or anything in its graph, has a top-level await, require() throws instead of blocking or returning something half-evaluated. This matches Node.js' ERR_REQUIRE_ASYNC_MODULE restriction. Because require() must return synchronously, there's no reasonable value to hand back for a module that hasn't finished evaluating. Use import() for async modules instead. The check holds regardless of import order: a module doesn't become require()-able just because something already imported and fully evaluated it.
One legacy compatibility path remains: if you're requiring output from a bundler that predates Node.js' require(esm) support and sets a truthy __cjsUnwrapDefault export as a marker, that takes priority and returns the default export. This exists so prebuilt bundles keep working.
Errors and module identity
Errors now use consistent classes and messages regardless of which loading path triggered them. Previously, the same failure could surface differently depending on whether it came through a static import, a dynamic import, or require().
Module specifiers are parsed as URLs, which means the URL parser canonicalizes equivalent paths. A module imported through ./a/../b.js and ./b.js resolves to the same instance. Fragment and query strings, however, are not normalized away, so ./b.js and ./b.js#x are distinct.
Lazy compilation and shared caching
Modules compile lazily today, only when first imported, whether statically or dynamically. Combined with the fact that node: built-ins resolve to the same module instance no matter how you reference them, this reduces unnecessary work across the runtime. WebAssembly modules also support source phase imports.
What this means for bundlers
Much of the code deployed to Workers arrives as a single bundled file. Wrangler uses esbuild by default, inlining relative imports and most npm dependencies into one module, replacing import and require() statements with regular functions. By the time that bundle reaches workerd, there often isn't much of a module graph left to resolve. How well this works depends on the bundler and the deployment mode.
With the Cloudflare Vite plugin, Vite 8 uses Rolldown to bundle your code. Rolldown resolves imports and npm dependencies, converts CommonJS to ESM where needed, and emits an entry module plus chunks from code splitting such as dynamic imports. When you deploy with --no-bundle, or your tooling uploads a Worker as multiple modules directly, the full module graph reaches the runtime exactly as written.
The new registry makes it practical for bundlers to perform fewer transformations and rely on the runtime for more module resolution. Node.js APIs, for instance, are built into workerd and resolved as specifiers rather than inlined as polyfills. Wasm, text, and binary modules can be provided as separate files and referenced by specifier.
For the complete technical breakdown of how the registry interacts with V8's module APIs, there's reference documentation available in the workerd repository.
A single, predictable error model
No matter which loading path fails—a static import, a dynamic import(), or a require() call—the error class and message format are now consistent:
await import('./nope.js');
// Error: Module not found: file:///bundle/nope.js
await import('https://');
// TypeError: Invalid module specifier: https://
A "Module not found" error is a plain Error, because it represents a failure to locate a resource rather than an invalid argument. If the specifier itself cannot be parsed as a URL, you get a TypeError, mirroring Node.js' ERR_INVALID_MODULE_SPECIFIER. Circular dependencies that V8 cannot unwind also throw a plain Error, never a TypeError. This consistency is especially valuable when building higher-level tooling on top of dynamic import(), such as custom loaders or retry wrappers, since you can now reliably branch on the error type or message regardless of how module loading was initiated.
Direct WebAssembly source phase imports
You can now import the compiled but not yet instantiated form of a WebAssembly module using source phase imports:
import source wasmModule from './add.wasm';
export default {
async fetch() {
const instance = await WebAssembly.instantiate(wasmModule, {});
return new Response(String(instance.exports.add(1, 2)));
},
};
Or dynamically:
const wasmModule = await import.source('./add.wasm');
Both approaches return a WebAssembly.Module instance directly, eliminating the need to import the module normally and then extract it from the default export. Since source phase imports are a new language feature, they currently work exclusively for WebAssembly. Attempting to use them on any other module type throws a SyntaxError, matching Node.js and other runtimes' behavior.
Enabling and testing the new registry
To try the rebuilt module registry, add the new_module_registry compatibility flag to your Worker:
{
"compatibility_flags": ["new_module_registry"]
}
The flag has no default activation date yet, so it will not be enabled automatically for new or existing Workers regardless of their compatibility date. You must opt in explicitly by adding the flag.
Feedback is welcome. Since workerd is open source, if you encounter behavior that appears to be a regression rather than one of the changes described here, please file an issue against the workerd repository.



