Cloudflare Module Registry 2026: What Markdown Sites Gain
On September 9, 2026, Cloudflare published a rewrite of the module registry inside workerd, the open-source core of the Workers runtime. It is an infrastructure post about module resolution, which sounds several layers removed from the work of converting Markdown to rich text. It is not. The module registry is the piece that decides whether a Node.js Markdown library can run unmodified at the edge, and the rewrite removes a class of failure that has quietly shaped how every lightweight Markdown service gets built.
This is a walk through what actually changed, what it means if you maintain a Markdown converter or a static site generator, and why the answer for most client-side tools is still "keep it in the browser."
What the module registry does
When you deploy a Worker, wrangler or Vite bundles your code and dependencies into one or more modules. By default, Wrangler uses esbuild to inline relative imports and most npm dependencies into a single file, replacing import and require() statements with regular function calls. Cloudflare notes these bundled scripts have been seen growing to hundreds of thousands of lines.
Something still has to take a specifier — the string inside an import — work out what code it actually points to, compile it, and hand V8 a module object it can link and run. In workerd, that is the module registry's job. Cloudflare's summary of why the old implementation needed replacing is worth reading closely, because it describes a real constraint and not just an aesthetic preference:
- The old registry resolved specifiers as filesystem-style paths, not URLs. That ruled out a clean implementation of
import.meta.url, made relative imports resolve differently fromnew URL(), and forced protocols likenode:andcloudflare:to be handled as special-cased string prefixes rather than protocols. - It compiled the entire Worker bundle up front, whether or not a given module was ever imported.
- It kept a separate private copy of everything per V8 isolate. Cloudflare runs multiple isolate replicas of the same Worker across CPU cores, so the same source was compiled more than once with multiple copies held in memory.
None of that was a bug in the sense of producing wrong output. It was a design that made the correct thing hard to express, which is the kind of constraint that eventually leaks into the libraries built on top of it.
Turning it on
The new registry is opt-in. You add one compatibility flag to your Worker's configuration:
{
"compatibility_flags": [ "new_module_registry" ]
}
There is no default-on date. Cloudflare is explicit that it will not turn on automatically for any Worker, old or new, no matter what compatibility date the Worker declares. You must add the flag. The existing registry is not going away, and Workers that do not set the flag keep running exactly as they do today.
The six behaviour changes that matter
1. import.meta works
The import.meta API now exposes 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}`);
},
};
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, and it recognises bare Node.js built-ins — import.meta.resolve('fs') returns node:fs.
There is one detail worth knowing if you ever inspect the output: resolution normalises percent-encoding the same way new URL() does, which collapses dot segments 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.
2. Specifiers are URLs, and query strings create distinct modules
Relative imports now resolve the same way new URL(specifier, base) would, because that is literally what happens underneath. Full URLs work as specifiers too. The genuinely surprising consequence is what query strings and fragments now do, following the module-identity rules browsers already use:
// 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`
The same source, evaluated twice, two separate copies of top-level state, each with its own import.meta.url. Importing the same specifier with the same query string again still returns the same instance, so this is not a mechanism for forcing re-evaluation. If you have ever wondered how bundlers implement hot module replacement and cache-busting across a module graph, this is the semantic that makes it possible.
3. Import attributes are validated instead of ignored
The old implementation silently ignored import attributes, which violates the specification. The new one throws. json is the only attribute type enabled, since it is the only relevant TC39 proposal at Stage 4. text and bytes are recognised but rejected with a specific error rather than silently ignored:
import data from './config.json' with { type: 'json' }; // works
import msg from './message.txt' with { type: 'text' };
// TypeError: Import attribute type "text" is not yet supported
import d from './config.json' with { type: 'json', cache: 'no' };
// TypeError: Unsupported import attribute: "cache"
The type has to match the module or you get TypeError: Module "./utils.js" is not of type "json". Above all else, this is a correctness change: a build that appeared to work while quietly discarding a declared assumption now fails loudly.
4. require() of an ES module follows Node.js rules
If you require() something that turns out to be an ES module — directly, or through createRequire() — the registry follows Node.js require(esm) behaviour. If the module has a string-named export called 'module.exports', that value is returned. Otherwise you get the module's namespace object. The one exception is workerd's own node: built-ins, implemented as ES modules wrapping a CommonJS-style API in a default export, so require('node:buffer').Buffer behaves as expected without unwrapping.
Alongside it comes a restriction: if the module you are requiring, or anything in its module graph, has a top-level await, require() throws rather than blocking or returning a half-finished value. That matches Node.js' own ERR_REQUIRE_ASYNC_MODULE. Use import() for anything async. The check holds regardless of import order — a module does not become require-able just because something already imported and fully evaluated it.
5. Errors use consistent classes
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 is a failure to locate something rather than a problem with the value passed in. A specifier that cannot be parsed as a URL at all is a TypeError, matching Node.js' ERR_INVALID_MODULE_SPECIFIER. A circular dependency V8 cannot unwind is also a plain Error, never a TypeError.
This matters most if you are building on top of dynamic import() — a custom loader, a retry wrapper, a plugin host — because you can now branch on error class or message reliably regardless of which loading path triggered the failure.
6. WebAssembly source phase imports
You can now import the compiled-but-not-instantiated form of a WebAssembly module directly, either statically with import source wasmModule from './add.wasm' or dynamically with await import.source('./add.wasm'). Either way you get a WebAssembly.Module back rather than reaching into a default export. As source phase imports are new to the language, this works only for WebAssembly right now; using it on any other module type throws a SyntaxError, matching Node.js and other runtimes.
What this actually changes for a Markdown tool
Cloudflare's own framing splits the compatibility problem in two. The first half is the API surface, and that work was largely done before this post: the runtime now supports every stable Node.js API useful in a serverless context, those APIs are enabled by default, and the compressed bundle size limit was removed — up to 64 MiB on all plans.
The second half is what this post addresses, and it is the half that breaks in practice. API compatibility alone is not enough, because Node.js applications also depend on how the runtime resolves, loads, and caches modules. A Markdown library that calls import.meta.url to locate a template, or uses import attributes to pull in a JSON config, or relies on require() interop with a CommonJS dependency, fails not because an API is missing but because module resolution behaved differently. That is a much harder failure to diagnose, because the error surfaces at a different layer from the cause.
Concretely, for anyone running or considering a server-side Markdown pipeline on Workers, the practical situation is now:
- Node.js Markdown libraries are more likely to run unmodified.
import.meta.url, import attributes, andrequire(esm)are the three specific behaviours most likely to break a library ported from a Node.js build, and all three now follow Node.js semantics. - Lazy compilation is designed in from day one. The old registry compiled the whole bundle up front; the new one compiles modules when first imported. For a service that pulls in a parser, a plugin set, and several renderers, that is startup cost you no longer pay for the code paths a given request does not touch.
- Bundlers can do less work. Cloudflare states directly that the new registry opens the door for bundlers like Rolldown to perform fewer transformations and rely more on the runtime to handle resolution, so the Workers runtime receives a smaller build-generated module graph rather than the application's original source graph.
- The migration cost is zero if you do not opt in. The flag is explicit, there is no auto-enable date, and existing Workers keep running as before.
Why the browser is still the right answer for conversion
There is a version of this story that reads as an invitation: Node.js compatibility keeps improving at the edge, so more Markdown processing can move server-side. That conclusion does not follow, and it is worth being precise about why.
The constraint on client-side Markdown conversion was never that the runtime could not parse Markdown. It was that a server cannot be trusted with the document. Every argument in favour of uploading a draft to be converted — no bundle to download, consistent rendering across devices, no WASM payload — trades away the property that makes a client-side converter worth using in the first place: the file never leaves the machine.
A Markdown converter that runs in the browser has no upload step, no retention window, no terms of service governing what happens to your draft, and no breach surface for documents that were never transmitted. Those are properties of the architecture, not features you can bolt onto a server-side implementation later. Better edge runtime compatibility makes server-side Markdown processing more feasible; it does not make it more private.
This is the same split that has run through every Markdown tool comparison on this blog, and the edge runtime story is another instance of it rather than an exception. The Cloudflare rewrite is genuinely good news for developers deploying Node.js applications to Workers — that is what it is for, and it does that job well. For the person who wants to paste a Markdown draft and get formatted rich text back for LinkedIn or Medium, the browser remains the correct place for the work to happen, because the browser is the only place where the document is never transmitted.
The pattern underneath
Step back and the September 2026 Cloudflare post fits a trend this blog has been tracking across the Markdown ecosystem: the format keeps getting adopted by surfaces it was never designed for, and every new surface creates pressure at both ends of a pipeline.
Agent memory systems now store canonical state as Markdown files with editing policies. Coding agents read instruction files written in Markdown. Slide decks are authored in Markdown and rendered by Slidev. Retrieval systems are being handed Markdown sources so that both readers and agents can find a page. And conversion to rich text is the step that carries the same document into publishing destinations. Each of these adds a converter at one end and a renderer at the other, and each one raises the same question: where does the processing happen, and what does the document touch on the way through?
Module registry rewrites are the plumbing underneath that question. They determine which libraries can run where, and therefore which architectural choices remain available. The interesting part of the Cloudflare announcement is not the flag — it is that the runtime no longer forces a decision for you.
FAQ
Is the new module registry on by default?
No. It requires the new_module_registry compatibility flag, and Cloudflare states there is no default-on date — it will not enable automatically for any Worker regardless of compatibility date. Workers without the flag continue using the existing implementation unchanged.
Will my existing Worker break if I enable it?
Cloudflare does not present it as a breaking change, and describes the old implementation as remaining in place. That said, two behaviours become stricter rather than looser: import attributes are validated instead of silently ignored, and require() on a module with top-level await now throws. If your code relied on either being quietly tolerated, that reliance was an unstated assumption worth knowing about.
Does this mean I should move my Markdown conversion server-side?
No. It means server-side Markdown conversion is more technically feasible on Workers than it was. Those are different claims. Feasibility says nothing about whether uploading your draft is a good idea, and a client-side converter that never transmits the document keeps a property that a server-side one cannot have.
Where can I read the full technical detail?
Cloudflare's post is at blog.cloudflare.com/workers-module-registry-nodejs, published September 9, 2026 by Logan Gatlin and James Snell. Cloudflare also added reference documentation to workerd breaking down how the new registry interacts with V8's module APIs, and workerd is open source if you want to file an issue.
Markdown in, rich text out — without the upload.
Cloudflare made the edge runtime better at running Node.js. md2rich takes the other approach: the conversion runs client-side in your browser, so the Markdown source never leaves your machine. Paste a draft, get formatted rich text for LinkedIn, Medium, X, or Notion.
Try md2rich