Cloudflare has overhauled the module registry within workerd, the open-source foundation of its Workers runtime, to deliver improved performance, stronger adherence to web standards, and closer alignment with how Node.js handles module resolution.
Over recent years, Cloudflare has progressively expanded support for Node.js runtime APIs. The Workers runtime now includes every stable Node.js API suitable for serverless deployment, and these are now active by default. This expansion allows developers to run substantially larger Node.js applications on Cloudflare—the platform now supports bundles up to 64 MiB across all plans, with the compressed bundle size limit removed entirely.
API support alone does not guarantee compatibility. Node.js applications rely on how the runtime resolves, loads, and caches modules. The Workers runtime must handle ESM, CommonJS, and WebAssembly modules, each with distinct requirements. The component responsible for this functionality is the module registry.
Developers can begin using the new implementation immediately by adding the new_module_registry compatibility flag to their Worker configuration:
{
"compatibility_flags": ["new_module_registry"]
}
Enabling this flag activates several key improvements:
import.meta.url,import.meta.main, andimport.meta.resolve()all function correctly- Module specifiers are parsed and resolved as actual URLs, including query strings and fragments
node:built-ins resolve to the same module instance regardless of import path- Import attributes (with
{ type: 'json' }) are properly validated require()on an ES module adheres to Node.js'require(esm)rules- Error classes and messages remain consistent across all loading paths
- Modules compile on-demand when first imported, whether statically or dynamically
- WebAssembly modules support source phase imports
How the Workers runtime loads deployed code
When a Worker is deployed to Cloudflare, wrangler or Vite bundles the Worker's code from multiple files and dependencies into one or more modules, which are then uploaded via wrangler deploy.
By default, Wrangler consolidates nearly all code into a single module script. It runs esbuild internally, which processes and inlines relative imports and require() calls for most npm dependencies into that single file. Import and require statements are replaced with regular functions during this process. By the time the bundle reaches the Workers runtime, there is typically minimal module graph remaining—most modules are consolidated into one file. These scripts can grow to hundreds of thousands of lines.
Why bundle multiple modules into a single file before uploading to Cloudflare? Although uploading multiple modules of different types has been technically feasible in the Workers runtime for years, the runtime has not resolved modules consistently with other runtimes. For example, if code or dependencies used import.meta.resolve() to determine another module's path, that code would fail because the method was unsupported.

When using the Cloudflare Vite plugin, Vite 8 bundles code with Rolldown instead of Wrangler using esbuild. Rolldown resolves imports and npm dependencies, converts CommonJS to ESM where needed, and emits an entry module plus additional chunks from code splitting, such as dynamic imports. The Workers runtime thus receives a smaller, build-generated module graph rather than the application's original source graph.
The new module registry implementation opens possibilities for bundlers like Rolldown to perform fewer transformations and rely more on the runtime for module resolution.

When importing a Node.js API in a worker, the runtime imports a module built into workerd by default—not a polyfill bundled into the code. Wasm, text, and binary modules are provided to the Workers runtime as separate files, referenced by specifier rather than inlined. When deploying with --no-bundle or uploading a Worker as multiple modules directly, the full module graph appears at runtime exactly as written.
In all these scenarios, something must take a specifier, determine what code it references, compile it, and provide V8 with a module object for linking and execution. In workerd, the module registry performs this function.

Why rebuild the module registry
The original registry resolves specifiers as filesystem-style paths rather than URLs. This distinction, though seemingly minor, prevented numerous features: there was no straightforward way to implement import.meta.url, relative imports did not follow the same resolution rules as new URL(), and protocols like node: and cloudflare: were handled as special-cased string prefixes instead of actual protocols.
Additionally, the original registry compiled the entire Worker bundle upfront, whether or not specific modules were ever imported. It maintained a separate, private copy of everything per V8 isolate. Since Cloudflare runs multiple V8 isolate replicas of the same Worker to distribute load across CPU cores, this meant compiling identical source multiple times and keeping multiple copies in memory.
While not technically bugs, these limitations made evolution of the implementation difficult without breaking changes. The new registry starts with URLs as the specifier format and incorporates lazy loading and cache sharing as core design principles from the start. The existing registry implementation remains unchanged. Currently deployed Workers will continue functioning as before.
import.meta
The import.meta API provides module information, such as the module's URL and whether it is the main entry point:
export default {
async fetch(request) {
return new Response(`${import.meta.url}, main: ${import.meta.main}`);
},
};
This outputs something like file:///bundle/index.js, main: true.
import.meta.main is true only for the module configured as the Worker's entrypoint; all other modules receive false.
import.meta.resolve() resolves a specifier against the current module without importing it:
import.meta.resolve('./utils.js'); // 'file:///bundle/utils.js'
import.meta.resolve('./a/../utils.js'); // 'file:///bundle/utils.js' (dot segments collapse)
import.meta.resolve('fs'); // 'node:fs' (recognizes bare node.js built-ins too)
It performs a pure string transformation, identical to Node.js and browser behavior: it does not verify that the resolved URL corresponds to an actual module, and it throws a TypeError for unparseable specifiers rather than returning null. One notable detail: it normalizes percent-encoding the same way new URL() does, collapsing paths like ./a/../b.js, but does not decode characters already percent-encoded. import.meta.resolve('%66oo.js') resolves to file:///bundle/%66oo.js, not file:///bundle/foo.js.
Specifiers are URLs
Relative imports now resolve identically to how new URL(specifier, base) would, because that is literally what occurs under the hood. Full URLs work as specifiers too, not merely relative paths:
import { helper } from 'file:///bundle/utils.js';
The more significant consequence involves query strings and fragments. Following the same module-identity rules browsers use, a specifier with a different query string or fragment is treated as a distinct module instance, even when pointing to identical underlying source:
// counter.js
let n = 0;
export function increment() {
return ++n;
}
import { increment as incA } from './counter.js?a';
import { increment as incB } from './counter.js?b';
incA(); // 1
incA(); // 2
incB(); // 1, a separate instance with its own copy of `n`
./counter.js?a and ./counter.js?b load the same source but evaluate separately, each receiving its own import.meta.url and its own copy of any top-level state. Importing the same specifier with the same query string again returns the same instance, so this is not a mechanism to force re-evaluation on every import.
Import attributes are correctly validated
import data from './config.json' with { type: 'json' };
The original module registry silently ignored import attributes, violating the specification. Implementations are expected to throw an exception when encountering unrecognized import attributes.
json is currently the only enabled import attribute type, as it is 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 are rejected with a specific error rather than silently ignored or treated as unsupported syntax:
import msg from './message.txt' with { type: 'text' };
// TypeError: Import attribute type "text" is not yet supported
Any attribute key other than type is now a hard error, rather than being ignored:
import data from './config.json' with { type: 'json', cache: 'no' };
// TypeError: Unsupported import attribute: "cache"
If the specified type does not match what the module actually is:
import data from './utils.js' with { type: 'json' };
// TypeError: Module "./utils.js" is not of type "json"
require(esm) follows Node.js' rules
If require() encounters an ES module, whether directly inside a CommonJS module or through require('node:module').createRequire(), the registry adheres to Node.js' require(esm) behavior:
- If the module exports a string-named export called 'module.exports', Node.js' mechanism for allowing an ES module to control what
require()sees, that value is returned - Otherwise,
require()returns the module's namespace object - The exception is workerd's own
node:built-ins, implemented as ES modules wrapping a CommonJS-style API in a default export, so requiring one returns that default export directly.require('node:buffer').Bufferbehaves as expected; no namespace object with a.defaultto unwrap
// utils.mjs
const impl = { hello: 'world' };
export { impl as 'module.exports' };
export default 'not this';
import { createRequire } from 'node:module';
const myRequire = createRequire(import.meta.url);
myRequire('./utils.mjs'); // { hello: 'world' }, not the module namespace
A restriction accompanies this: if the module being required, or anything in its module graph, contains top-level await, require() throws instead of blocking or returning something incomplete:
// async-init.mjs await Promise.resolve(); export const ready = true;
myRequire('./async-init.mjs');
// Error: Top-level await is not supported in this context for module: file:///bundle/async-init.mjs
This matches Node.js' own ERR_REQUIRE_ASYNC_MODULE restriction: require() must return synchronously, and no reasonable value exists for a module not yet fully evaluated. Use import() for anything asynchronous instead. The check applies regardless of import order: a module does not become require()-able simply because something previously import()'d and fully evaluated it.
If requiring output from a bundler predating Node.js' require(esm) support that sets a truthy __cjsUnwrapDefault export as a marker, that takes priority over both rules above and returns the default export. This exists solely to maintain compatibility with existing prebuilt bundles.
Errors are consistent, and use the right class
Whether resolution fails through a static import, a dynamic import(), or require(), the same error class with the same message shape is returned:
await import('./nope.js');
// Error: Module not found: file:///bundle/nope.js
await import('https://');
// TypeError: Invalid module specifier: https://
"Module not found" is a plain Error, since it represents a failure to locate something rather than a problem with the provided value. A specifier that cannot be parsed as a URL is a TypeError, matching Node.js' own ERR_INVALID_MODULE_SPECIFIER. A circular dependency that V8 cannot unwind is also a plain Error, never a TypeError. This distinction matters primarily when building on dynamic import(), such as custom loaders or retry wrappers, since error class or message can now be used reliably to branch logic regardless of which loading path triggered it.
WebAssembly source phase imports
The compiled-but-not-instantiated form of a WebAssembly module can now be imported directly 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)));
},
};
const wasmModule = await import.source('./add.wasm');
Either approach returns a WebAssembly.Module directly, rather than importing the module normally and extracting it from the default export. As source phase imports are a new language feature, this currently works only for WebAssembly; attempting it on any other module type throws a SyntaxError, matching Node.js and other runtimes' behavior.
What's next
Developers should test the new implementation by adding the new_module_registry compatibility flag to their Worker:
{
"compatibility_flags": ["new_module_registry"]
}
The flag has no default activation date, so it will not enable automatically for any Worker, whether new or existing, regardless of compatibility date. The flag must be added explicitly.
Feedback is welcome. workerd is open source. For behavior that appears to be a regression rather than one of the described changes, file an issue against the workerd repository.