Node.js 22: Experimental ESM Require and V8 v12.4
The Node.js 22 release candidates have been floating around for a few weeks, and the headline feature is exactly what the community’s been screaming for since ESM first showed up: you can finally require() an ES module.
Well. Sort of. Behind a flag. And only if there’s no top-level await. But still — progress.
I’ve been running the RC against our codebase at DreamFlare to see what breaks and what actually gets easier. Here’s what jumped out.
The ESM/CJS divide, briefly #
If you’ve wrangled a Node.js codebase of any real size, you’ve felt this pain. ES modules and CommonJS don’t play nice. Want to use an ESM-only library? Better hope your entire dependency chain is ready for that ride. Want to require() something modern? Nope — require() doesn’t speak import/export.
The workaround’s been dynamic import(), which returns a Promise. Meaning you can’t use it synchronously at the top of a CommonJS file. So you end up with awkward async wrappers, conditional imports buried in functions, or just… maintaining two module systems side by side like some kind of digital bilingual household.
It’s been years. The community’s written approximately ten thousand blog posts about this. Every Node release, people ask “is the CJS/ESM interop fixed yet?” and the answer’s consistently been “no.”
Node.js 22 changes that to “experimentally, yes.”
How –experimental-require-module works #
Behind the --experimental-require-module flag, Node.js 22 lets you require() an ES module — as long as the entire module graph stays synchronous. No top-level await anywhere in the chain. If the module and everything it imports are synchronous, require() loads it and hands back the namespace object.
// This now works (with the flag):
const { something } = require('esm-only-package');The technical constraint makes sense. require() is synchronous by design — it blocks until the module loads and evaluates. Top-level await makes a module asynchronous by definition. You can’t bridge that gap without fundamentally changing what require() means. So the Node team drew a reasonable line: synchronous ESM graphs work, async ones don’t.
In practice, this covers a surprising chunk of cases. Most ESM-only packages don’t use top-level await. The ones that do tend to be doing something unusual — loading WASM modules, fetching configuration, initializing database connections at import time. For the typical utility library that went ESM-only because “it’s the future,” require() should just work.
I tested it against about forty of our dependencies. Thirty-six loaded fine. The four that didn’t all had top-level await lurking in their dependency trees (two were WASM-related, one was a config loader, one was a database driver). That’s a 90% hit rate — better than I expected.
V8 12.4: Maglev by default #
The V8 upgrade to 12.4 is the kind of change you don’t notice until you benchmark something. Maglev — V8’s mid-tier optimizing compiler, sitting between Sparkplug and TurboProp — is now enabled by default.
What does that mean practically? Faster startup for short-lived processes. CLI tools, serverless functions, build scripts — anything that runs briefly and doesn’t give TurboProp enough time to warm up. Maglev compiles code faster than TurboProp (with slightly less optimized output) and that tradeoff pays off for processes that exit before TurboProp would’ve finished its first optimization pass.
I ran our CI suite with Node 22 RC versus Node 21 and saw a consistent 8-12% improvement in test execution time. Nothing dramatic, but the kind of free performance gain that compounds across a team.
New language features worth knowing #
V8 12.4 brings several new JavaScript features. A few that caught my eye:
Array.fromAsync does exactly what you’d think: creates an array from an async iterable. If you’ve written the boilerplate of collecting async iterator results into an array, this replaces it with a one-liner.
const results = await Array.fromAsync(asyncGenerator());Set methods are finally here. union(), intersection(), difference(), symmetricDifference(). I’ve been reaching for lodash for set operations for years. Now it’s built in.
const a = new Set([1, 2, 3]);
const b = new Set([2, 3, 4]);
a.intersection(b); // Set {2, 3}
a.difference(b); // Set {1}
Iterator helpers let you chain .map(), .filter(), .take(), .drop() on iterators without converting to arrays first. This matters for memory-sensitive operations on large datasets.
These aren’t Node-specific — they’re V8/JavaScript features. But Node 22 is where most server-side developers will encounter them first.
WebSocket client without a flag #
The WebSocket client that shipped experimentally in Node 21 is now available without the --experimental flag. If you’ve been pulling in ws or websocket packages purely for client-side WebSocket connections, you can drop the dependency.
const ws = new WebSocket('wss://example.com/socket');
ws.addEventListener('message', (event) => {
console.log(event.data);
});The API matches the browser’s WebSocket API, which means isomorphic code gets a bit easier. Server-side WebSocket libraries still offer more control (connection pooling, custom headers, backpressure handling), but for simple client connections, the built-in option is solid.
LTS timeline #
Node.js 22 enters Current status when it officially releases later this month. LTS promotion is scheduled for October 2024. If you’re running a production system, that’s when you’d typically adopt — after six months of the community finding and fixing edge cases.
For greenfield projects though? I’d start on 22 now. The ESM require() interop alone justifies it. We’re already running the RC in our dev environments at DreamFlare and haven’t hit any blockers beyond the expected top-level await incompatibilities.
The bigger picture #
The ESM/CJS split has been the single most annoying aspect of the Node.js ecosystem for the past five years. It’s fractured the community, created maintenance burden for library authors who publish dual formats, and confused newcomers who just want to write JavaScript without a graduate seminar in module systems.
--experimental-require-module won’t heal all of that overnight. It’s behind a flag, it has limitations, and it’ll take time for the ecosystem to adjust. But the direction is finally, unambiguously right. The Node team is acknowledging that the two module systems need to coexist — and they’re building the bridges.
By the time this becomes stable (maybe Node 23 or 24), I think we’ll look back at the CJS/ESM divide the way we look at the callback-to-promises migration: painful at the time, obviously necessary in retrospect, and eventually invisible.
For now, try the flag. See what breaks. File bugs. The faster the community pressure-tests this, the faster it stabilizes.