In the future using top-level await might be cause a backwards compatibility break in Node
Node 23 was released this week, and the hot ticket item probably is the
fact that you can now require()
files that use ESM (import
/export
).
This is helpful because ESM and CommonJS (require
/module.exports
) are kind
of different worlds and before this change if you wanted to use a “module”
from your CommonJS file, you would need to do something like:
const theThing = await import('some/module/file.mjs');
This is called a dynamic import and it’s extra annoying, because you
can’t just put this at the top of your file. It returns a Promise, so you
can only import these modules ‘asynchronously’ or in functions. The reason
is that you can only await
inside functions in CommonJS files. So this
syntax prevents importing a “module” and immediately using it. To use a
module in CommonJS, you’ll need some kind of initialization logic.
Many Javascript packages resort to shipping both a ‘CommonJS’ and a ‘ESM’ version of their code to reduce this kind of annoyance, but not everyone does.
The Node 23 change
Node 23 makes it possible to load ESM modules transparently via require()
.
This means that the above example can be rewritten to:
const theThing = require('some/module/file.mjs');
The important part here is not the usage of require
, but the absense of
await
. This allows ESM modules to be loaded without this kind of
initialization logic.
But there’s a big caveat:
This only works if the module you loaded in is not using top-level await. “Top level await” means awaiting things outside of (async) functions, which is possible in modules (but not CommonJS).
If a top-level await was used, a ERR_REQUIRE_ASYNC_MODULE
error will be
thrown. But the important thing to note is that this doesn’t just apply to
the file you are directly importing/requiring. It also applies to any files
loaded by that file, at any level in either your project or any dependency or
sub-dependencies.
Using top-level await is now a BC break
Typically when we think of backwards compatibility breaks that require a major new version in semver, you might think of functions changing, or removing or arguments no longer being supported.
Before this change in Node, if your project was fully switched to ESM you might
not think of placing a top-level await
anywhere in your code as a backwards
compatibility break, but if it’s the first await
you might now inadvertently
break Node.js users, if they used require()
to bring in your module.
This means that the first top-level await
in your project (or any of your
dependencies) might now constitute a new major version if you follow semver.
If you don’t want to do this, here are some other things you could do:
1. Tell your users you don’t support require()
You could effectively tell them if they use require()
they’re on their own.
Chances are that your users won’t read this and do it anyway, but arguably they
should read the readme of a package they bring in.
2. Add a dummy await
Do you think you or your dependencies might use a top-level await in the future, but you don’t yet? You could add this line to your source to reserve the right to do it later:
await "Good things come to those that support await"
3. Explictly break CommonJS
Packages can provide both CommonJS and ESM support via the exports
key
in package.json
. It could be an option to export a single CommonJS file that
just throws an error to warn CommonJS users they shouldn’t use this package.
Out of these 3 options, I kind of like 2 because if Node.js ever changes behavior and does support loading modules that use top-level await in CommonJS files it will one day just start working.
Regardless. it does feel important to me that package maintainers that only ship ESM, or ship CommonJS and intend to use this new feature have a strategy, because it’s bound to blow up if not.
How do other runtimes do it?
In Bun you can use import
and require
in the same file, so there’s
fewer trade-offs to make. But if you do use require()
to import a module
with top-level await it still has the same issue. There’s just fewer reasons
in Bun to even want to do this.
Deno doesn’t support CommonJS so it doesn’t have that problem.
Does the issue exists when importing CommonJS files into modules?
Importing CommonJS files into modules was always possible via the standard
import
syntax, and because CommonJS can’t asynchronously export this
problem doesn’t exist there.
What should I do as a user?
Most of this article was about the effects for package maintainers, but not every package will do this well so as a user you’re still potentially exposed to this issue.
In order of preference, consider the following:
- Stop using CommonJS. Start using ESM everywhere. This issue only exists in CommonJS files using ESM, not the reverse. ESM is the future. You’re working with dead-end technology.
- Have some testing in place that at the very least loads the entirely of your
code-base. If all (top-level, not-dynamic)
requires()
are ran/resolved, any changes in depndencies should just blow up your CI environment and application. - Find out if any of your dependencies are ESM-only, and never
require()
them. This protects you from issues with direct dependencies. But keep in mind that this can still be an issue in sub-dependencies even if your direct dependencies are CommonJS themselves!
My opinion
For the above reasons I don’t think this should land in a stable Node.js version. The intentions are good but it creates yet another avenue of confusion.
It effectively expands the number of Javascript flavors from 2 to 3:
- CommonJS
- ESM
- ESM but without top-level await.
If a module uses flavor #3, it’s compatible with flavor #1, but if anywhere in the dependency tree something starts using top-level await, suddenly the entire tree ESM dependency tree cascades from flavor #3 to #2 and breaks compatibilty with flavor #1.
Given the already confusing landscape (and reputation) of Javascript modules, this feels like a step in the wrong direction, as it adds a feature to CommonJS (which in my opinion is time to freeze), and makes ESM less reliable as a result.