WritingCloudflare (Workers AI)Cloudflare (Workers AI)published Sep 9, 2026seen 1h

How we rebuilt Cloudflare Workers’ module registry for Node.js compatibility

Open original ↗

Captured source

source ↗

How we rebuilt Cloudflare Workers’ module registry for Node.js compatibility | Cloudflare Blog

Skip to content

We’ve rewritten the module registry in 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 and more 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 , letting you deploy even larger Node.js apps 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 . 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 in your Worker. { "compatibility_flags" : [ "new_module_registry" ] } When you enable the new_module_registry compatibility flag: import.meta.url , import.meta.main , and import.meta.resolve() all work. Module specifiers are parsed and resolved as real URLs, including query strings and fragments. node: built-ins resolve to the same module instance no matter how you reach them. Import attributes ( with { type: 'json' } ) are correctly validated. require() on an ES module follows Node.js' require(esm) rules. Errors use consistent classes and messages regardless of which loading path triggered them. Modules compile lazily when first imported (statically or dynamically). WebAssembly modules support source phase imports.

For the full deep-dive on how this new module registry interacts with V8’s module APIs, we’ve added reference docs to workerd 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. How the Workers runtime loads the code you give it When you deploy a Worker to Cloudflare, wrangler or Vite “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 ), 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() 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 , Vite 8 bundles your code using Rolldown , instead of Wrangler bundling your code using esbuild . 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 . 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 , 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. Why a new implementation? 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. import.meta The import.meta API provides information about the module, such as the module's URL, and whether it is the main entry point module: export default { async fetch ( request ) { return new Response ( ${ import . meta . url }, main: ${ import . meta . main } ); }, }; That prints something like...

Excerpt shown — open the source for the full document.

Notability

notability 5.0/10

Substantive Cloudflare engineering post, low HN traction.