shiichan

Node.js Compatibility and the App Size Limit, Both Solved! Cloudflare Workers Rebuilds Its Module Registry

Hi, it's Shii! Today I'm sharing something from the Cloudflare Blog. workerd, the open-source core of the Workers runtime, just had its "module registry" completely rewritten, and Node.js compatibility took a big leap forward. It might sound like a dry internals story, but it's actually a big deal for how big an app you can build on Workers, so let's dig in!

Cloudflare Blog blog.cloudflare.com

What was announced?

What Cloudflare announced on its blog is a rebuild of workerd's module registry, the mechanism that decides how modules find, load, and cache each other. This rewrite delivers three big things.

  • Stable Node.js APIs now work by default (Node.js compatibility is on by default)
  • The app size limit was raised to 64 MiB on every plan (the compressed bundle size limit is gone)
  • A new URL-based module resolution system landed, bringing support for import.meta, lazy compilation, and code caches shared across multiple V8 isolates

True to an engineering blog, this one is packed with technical detail!

The story so far

Until now, running a sizeable Node.js app on Workers came with some quiet friction.

For one, module resolution didn't quite match real Node.js. import.meta.resolve(), for instance, wasn't supported. So if some code, say inside a Vite plugin, called that API, that part just wouldn't work.

To work around this, Wrangler (or Vite) had to bundle multiple files together into one big module ahead of time. It worked, but it meant pre-transforming things that really should have been left to runtime module resolution, which tended to make the build pipeline more complex than it needed to be.

On top of that, there was a compressed bundle size limit, so bringing in a larger Node.js application as-is could be a tight squeeze.

What changes

The simplest win is that the size limit was raised to 64 MiB on every plan (the compressed bundle size limit is gone). Larger Node.js applications that couldn't fit before are now much easier to bring onto Workers.

And at the core of this release, enabling the new module registry switches resolution over to real URL resolution, the same behavior as new URL(specifier, base). That gets you:

  • import.meta.url, import.meta.main, and import.meta.resolve() all actually work
  • Specifiers that differ only by query string or fragment (like ./counter.js?a vs ./counter.js?b) load the same source but are treated as separate module instances, each with its own top-level state
  • node: built-ins always resolve to the same instance no matter how you import them

So bundlers like Rolldown no longer need to pre-bundle everything just in case; they can lean on runtime module resolution for more of the work. Simpler builds should mean a better developer experience too.

Dive Deep

Since this is a pretty technical release, let's dig a little further.

You need a compatibility flag to turn it on: the new registry isn't enabled by default yet, it requires explicit opt-in.

{
  "compatibility_flags": ["new_module_registry"]
}

Import attributes currently only support json: validation of import attributes, like import data from './config.json' with { type: 'json' }, is now stricter. Only json is supported today; text and bytes are planned as follow-ups.

require(esm) follows Node.js's rules: when CommonJS code calls require() on an ES module, the behavior now matches Node.js's require(esm) rules. If a module has an export named module.exports, that's what gets returned.

Error messages are now consistent: whether resolution fails through a static import, a dynamic import(), or require(), the error now comes back with the same class and format. That makes it much easier to write code that branches on error class or message.

Lazy compilation: modules are now compiled only the moment they're actually imported, so unused modules no longer get compiled for nothing.

Code caches shared across isolates: the compiled code cache for a given module can now be shared across multiple V8 isolate replicas, a quiet but welcome memory efficiency win.

WebAssembly source phase imports are supported too: writing import source wasmModule from './add.wasm' now gets you the compiled-but-not-yet-instantiated WebAssembly.Module directly.

Wrap-up

  • Stable Node.js APIs now work by default
  • The app size limit was raised to 64 MiB on every plan (the compressed size limit is gone)
  • The new, standards-compliant module registry, with import.meta-family APIs, lazy compilation, and shared code caches, is ready to try behind the new_module_registry flag
  • Error messages for failed module resolution are now unified

If you want to run a serious Node.js app on Workers, or you're the kind of person who keeps close tabs on Vite and Rolldown build internals, this is an update that quietly delivers a lot from deep inside the runtime!