Article summary — original
Cloudflare Rebuilds Workers Module Registry for Deeper Node.js Compatibility
Cloudflare has rewritten the module registry in its open-source runtime, workerd, transitioning to URL-based module resolution, shared caching across V8 isolates, and closer alignment with Node.js module behaviors. The new registry is currently available via the new_module_registry compatibility flag.
The new registry shifts from filesystem paths to URL-based resolution, introducing support for import.meta.url, import.meta.main, and import.meta.resolve().
It implements Node.js-aligned require(esm) semantics, forbidding synchronous require calls on modules containing top-level await while unifying module error reporting.
The update enforces strict import attribute validation for JSON modules and adds support for WebAssembly source phase imports.
URL Resolution and Isolate Efficiency
The previous module registry handled specifiers as filesystem paths and compiled full worker bundles upfront separately for each V8 isolate. The new registry treats specifiers as URLs, enabling native support for import.meta properties and allowing modules with different query strings or fragments to be evaluated as distinct module instances. It also incorporates lazy compilation and shared cache across multiple isolate replicas running across CPU cores.
Node.js require(esm) and Error Harmonization
When CommonJS code requires an ES module, the new registry aligns with Node.js require(esm) rules, correctly returning module exports while throwing an error if the imported module or its dependency graph contains a top-level await. Additionally, error shapes are standardized across static imports, dynamic imports, and require calls, returning a TypeError for invalid URL specifiers and a standard Error for missing modules or unresolved circular dependencies.
Import Attributes and Source Phase WebAssembly
Unlike the previous system that silently ignored unsupported import attributes, the new registry strictly validates attributes against TC39 standards, allowing only type: 'json' while throwing hard errors for unrecognized attributes. It also introduces source phase imports, allowing developers to import compiled, uninstantiated WebAssembly.Module objects directly via static or dynamic import source statements.
Saved original article
We’ve rewritten the module registry in [`workerd`](https://github.com/cloudflare/workerd), the core open-source component of the Workers runtime, to be faster, more standards-compliant, and more closely aligned with Node.js' module registry.
Over the past few years, we’ve been adding support for [more](https://blog.cloudflare.com/nodejs-workers-2025/) and [more](https://blog.cloudflare.com/bringing-node-js-http-servers-to-cloudflare-workers/) Node.js runtime APIs. The Workers runtime now supports every stable API from Node.js that you might want to use in a serverless context, and these APIs are now [enabled by default](https://developers.cloudflare.com/changelog/post/2026-08-04-nodejs-compat-default/), letting you deploy [even larger Node.js apps](https://developers.cloudflare.com/changelog/post/2026-09-04-increased-worker-size-limit/) to Cloudflare (now up to 64 MiB on all plans — we’ve removed the limit on compressed bundle size).
But API compatibility alone is not enough: Node.js applications also depend on how the runtime resolves, loads, and caches [modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules). ESM, CommonJS, and WebAssembly are each types of modules that you can import in your Worker’s code. The system within the runtime that handles all of this is called the module registry.
You can start using it today by enabling the [`new_module_registry` compatibility flag](https://developers.cloudflare.com/workers/configuration/compatibility-flags/new-module-registry) in your Worker.
When you enable the `new_module_registry` compatibility flag:
`import.meta.url`, `import.meta.main`, and `import.meta.resolve()` all work.`node:` built-ins resolve to the same module instance no matter how you reach them.`with { type: 'json' }`) are correctly validated.`require()` on an ES module follows Node.js' `require(esm)`
For the full deep-dive on how this new module registry interacts with V8’s module APIs, we’ve added [reference docs to workerd](https://github.com/cloudflare/workerd/blob/main/docs/reference/detail/new-module-registry.md) that break down everything in detail. But for most people building on Workers, you want to understand how these changes improve compatibility and help you build. To do that, we’ll dive into each of these changes in the sections below.
When you deploy a Worker to Cloudflare, [wrangler](https://developers.cloudflare.com/workers/wrangler/) or [Vite](https://developers.cloudflare.com/workers/vite-plugin/) “bundles” all of your Worker’s code from many files and dependencies into one or many modules, which are then uploaded to Cloudflare when you run `wrangler deploy`.
By default, Wrangler bundles nearly all of this code into a single module script. It runs `esbuild` under the hood, which processes then inlines relative imports and `require()` calls for most npm dependencies into that one file. The `import` and `require()` statements are replaced with regular functions as part of the process. By the time that bundle reaches the Workers runtime ([`workerd`](https://github.com/cloudflare/workerd)), there usually isn't much of a module graph left for the Workers runtime to deal with. Most of the different modules are bundled into one file. We have seen these scripts grow to as many as multiple hundreds of thousands of lines long.
Why is it necessary to bundle many modules into a single file before uploading server-side code to Cloudflare? It has been technically possible to upload multiple modules, and even modules of different types, in the Workers runtime for many years now. However, the runtime has not resolved modules in a way that was consistent with all the other runtimes. If, for example, your code or dependencies used [`import.meta.resolve()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta/resolve) to resolve the path to another module, that code would fail because `import.meta.resolve()` was not supported.
When you use the [Cloudflare Vite plugin](https://developers.cloudflare.com/workers/vite-plugin/), [Vite 8](https://vite.dev/blog/announcing-vite8) bundles your code using [Rolldown](https://rolldown.rs/), instead of Wrangler bundling your code using [esbuild](https://esbuild.github.io/). Rolldown resolves imports and npm dependencies, converts CommonJS to ESM where necessary, and emits an entry module plus any additional chunks created through code splitting, such as [dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import). As a result, the Workers runtime receives a smaller, build-generated module graph rather than the application’s original source graph.
The new module registry implementation in the Workers runtime opens the door to bundlers like Rolldown to perform fewer transformations, and to rely more on the runtime to handle module resolution.
When you import a Node.js API in your worker, by default you are importing a module that is built into `workerd`. It is not bundled into your code as a polyfill. Wasm, text, and binary modules are provided to the Workers runtime as separate files too. They are referenced by specifier instead of being inlined. And if you deploy with [`--no-bundle`](https://developers.cloudflare.com/workers/wrangler/bundling/#disable-bundling), or your tooling uploads a Worker as multiple modules directly, the full module graph shows up at runtime exactly as you wrote it.
In all of these cases, something has to take a specifier, work out what code it actually points to, compile it, and hand V8 a module object it can link and run. In `workerd`, that's the module registry's job.
The original registry resolves specifiers as filesystem-style paths, not URLs. That sounds like a minor distinction, but it ruled out a bunch of things: 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 instead of, well, protocols.
It also compiles your entire Worker bundle up front, whether or not a given module ever gets imported, and it keeps a separate, private copy of everything per V8 isolate. Cloudflare runs multiple V8 isolate replicas of the same Worker to spread load across CPU cores, so in practice that meant compiling the exact same source more than once, with keeping multiple copies of the source in memory.
None of this is really a bug, but it made it difficult to evolve the implementation without breaking changes. The new registry starts from URLs as the specifier format and treats laziness and cache sharing as things to design in from day one. The existing registry implementation is not going anywhere. Currently, deployed Workers will continue to work as they always have.
The [`import.meta`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import.meta) API provides information about the module, such as the module's URL, and whether it is the main entry point module:
That prints something like `file:///bundle/index.js, main: true`.
`import.meta.main` is true only for the module configured as your Worker's entrypoint; every other module gets `false`.
`import.meta.resolve()` resolves a specifier against the current module without importing it:
It's a pure string transform, same as in Node.js and in browsers: it doesn't check that the resolved URL corresponds to a real module, and it throws a `TypeError` for a specifier that can't be parsed as a URL at all, rather than returning `null`. One detail worth knowing if you ever look closely at the output: it normalizes percent-encoding the same way `new URL()` does, which means it collapses paths like `./a/../b.js`, but it does not decode characters that were already percent-encoded. `import.meta.resolve('%66oo.js')` resolves to `file:///bundle/%66oo.js`, not `file:///bundle/foo.js`.
Relative imports now resolve the same way as `new URL(specifier, base)` would, because that's literally what's happening under the hood. Full URLs work as specifiers too, not just relative paths:
The more interesting consequence is what happens with query strings and fragments. Per the same module-identity rules browsers use, a specifier with a different query string or fragment is treated as a genuinely distinct module instance, even when it points at the same underlying source:
`./counter.js?a` and `./counter.js?b` load the same source, but they're evaluated separately, each gets its own `import.meta.url`, and each gets its own copy of any top-level state. Importing the same specifier with the same query string again still gets you back the same instance, so this isn't a way to force re-evaluation on every import.
The original module registry implementation silently ignores the import attributes in violation of the spec. It is expected that implementations throw an exception when any import attribute it does not understand is used.
`json` is the only import attribute type enabled right now, since it's the only one of the relevant [TC39](https://tc39.es/) proposals that has reached Stage 4. `text` and `bytes` are recognized, because they track the [Import Text](https://github.com/tc39/proposal-import-text) and [Import Bytes](https://github.com/tc39/proposal-import-bytes) proposals, but they're rejected with a specific error instead of being silently ignored or treated as unsupported syntax:
Any attribute key other than `type` is now a hard error too, rather than being ignored:
And if the type you specify doesn't match what the module actually is:
If you `require()` something that turns out to be an ES module, whether that's directly inside a CommonJS module or through `require('node:module').createRequire()`, the registry follows Node.js' [`require(esm)`](https://nodejs.org/api/modules.html#loading-ecmascript-modules-using-require) behavior:
`'module.exports'`, Node.js' actual mechanism for letting an ES module control what require() sees, that value is returned.`workerd`'s own `require('node:buffer').Buffer` behaves the way you'd expect; you don't get a namespace object with a `.default` you need to unwrap yourself.
There's a restriction that comes along with this: if the module you're requiring, or anything in its module graph, has a top-level `await, require()` throws instead of blocking or handing back something half-finished:
This matches Node.js' own [`ERR_REQUIRE_ASYNC_MODULE`](https://nodejs.org/api/errors.html#err_require_async_module) restriction: `require()` has to return synchronously, and there's no reasonable value to hand back for a module that hasn't finished evaluating yet. Use `import()` for anything async instead. The check holds regardless of import order too: a module doesn't become `require()`-able just because something already `import()`'d and fully evaluated it earlier.
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 over both rules above and returns the default export. That's purely there so existing prebuilt bundles keep working.
Regardless of whether resolution fails through a static `import`, a dynamic `import()`, or `require()`, you get the same class of error with the same message shape:
`"Module not found"` is a plain `Error`, since it's a failure to locate something rather than a problem with the value you passed in. A specifier that can't be parsed as a URL at all is a `TypeError`, matching Node.js' own `ERR_INVALID_MODULE_SPECIFIER`. A circular dependency that V8 can't unwind is also a plain Error, never a `TypeError`. This mostly matters if you're building something on top of dynamic `import()`, like your own loader or a retry wrapper, since you can now branch on the error class or message reliably no matter which loading path triggered it.
You can now import the compiled-but-not-instantiated form of a WebAssembly module directly, using [source phase imports](https://github.com/tc39/proposal-source-phase-imports):
or dynamically:
Either way you get a `WebAssembly.Module` back directly, instead of importing the module normally and pulling it off the `default` export. As source phase imports are a new feature of the language, right now this only works for WebAssembly; trying it on any other module type throws a `SyntaxError`, matching the behavior of Node.js and other runtimes.
Try it out! Add the new\_module\_registry compatibility flag to your Worker:
It doesn't have a default on date yet, so it won't turn on automatically for your Worker, old or new, no matter what compatibility date it's using. You will need to add the flag explicitly.
We’d love your feedback. `workerd` is open source. If you run into behavior that looks like a regression rather than one of the changes described here, please file it against the [`workerd` repository](https://github.com/cloudflare/workerd).