[HN Gopher] Move on to ESM-Only
___________________________________________________________________
Move on to ESM-Only
Author : bpierre
Score : 102 points
Date : 2025-03-24 00:35 UTC (22 hours ago)
(HTM) web link (antfu.me)
(TXT) w3m dump (antfu.me)
| airstrike wrote:
| _> The Toolings are Ready_
|
| _> Modern Tools_
|
| _> With the rise of Vite as a popular modern frontend build
| tool, many meta-frameworks like Nuxt, SvelteKit, Astro,
| SolidStart, Remix, Storybook, Redwood, and many others are all
| built on top of Vite nowadays, that treating ESM as a first-class
| citizen._
|
| _> As a complement, we have also testing library Vitest, which
| was designed for ESM from the day one with powerful module
| mocking capability and efficient fine-grain caching support._
|
| _> CLI tools like tsx and jiti offer a seamless experience for
| running TypeScript and ESM code without requiring additional
| configuration. This simplifies the development process and
| reduces the overhead associated with setting up a project to use
| ESM._
|
| _> Other tools, for example, ESLint, in the recent v9.0,
| introduced a new flat config system that enables native ESM
| support with eslint.config.mjs, even in CJS projects._
|
| I think the author and I must have very different definitions for
| the word "ready"
| vosper wrote:
| How would you define "ready" here?
| airstrike wrote:
| Definitely "stable", but also "simple" and "straightforward"
| thiht wrote:
| So you need a bundler, a linter and a test runner. How's
| that any different from the experience in other languages
| where you need a compiler, a linter and a test runner?
| airstrike wrote:
| s/a/one
| paulddraper wrote:
| In my experience, the biggest obstacle to ESM in production
| systems is every observability/tracing tool relies on require-in-
| the-middle or in some other form CJS require().
| o11c wrote:
| Even "require at the start" (mandatory for transparent
| polyfills) seems incompatible with the way ESM wants to do
| things.
| jauntywundrkind wrote:
| They key thing is that require(esm) shipped in node 22, and is
| being back ported to node 20.
|
| Since Node 18 maintenance ends in less than a month, this means
| all Node versions will have good esm support, including for
| consuming esm-only libraries (which until recently did not work!)
|
| This is noted somewhat in the article, but basically is the whole
| story to me. Its now _possible_ to stop doing CJS libraries
| entirely. And with that, I don 't see why we would do CJS at all.
| 3np wrote:
| There are still maintained and supported older versions of
| node.js by the likes of Debian, Ubuntu, Enterprise Linuxes.
| They backport security fixes and such during a longer extended
| window but are unlikely to port ESM-require support. It may not
| be relevant to you and obv nobody's obliging you to also
| support those users, just saying it's not that binary.
| mattmanser wrote:
| Is that really "support those users"?
|
| If they need to use a new library version, they can install
| their own version of node instead of relying on the OS
| supplied one.
|
| All the OS version is doing is supplying the convenience of
| not having to install it.
|
| Or have I misunderstood how those versions of node are used?
| 3np wrote:
| Sometimes the entire reason why they are still on that
| older LTS dist is because they still _need_ (e.g.) node 12
| for some reason.
| mattmanser wrote:
| That really becomes a "not your problem" as a library
| author.
|
| If a user wants to use an old version of node, they'll
| have to use an old version of the library that still
| supports cjs.
| rafaelmn wrote:
| If you are pinning to unsupported node you should not expect
| new npm packages to work, ESM or not.
|
| However given my NPM experiences in the past, I would not be
| surprised that someone updated to ESM in a revision bump.
| forty wrote:
| Funnily my conclusion was, on the contrary, that it's now
| possible to ignore ESM entirely when using/targeting nodejs ;)
| forty wrote:
| (sadly at work, at this point we already have done the
| useless work of migrateming to ESM for no good reason other
| than having libs that were ESM only)
| 9dev wrote:
| ESM has heaps of benefits: being able to do static
| analysis, or limiting the exposed modules in a package, for
| example.
| j-krieger wrote:
| Tree-shaking also.
| jampekka wrote:
| Static analysis and tree shaking have been done with CJS
| for about a decade.
|
| https://gist.github.com/joepie91/bca2fda868c1e8b2c2caf76a
| f7d...
| wffurr wrote:
| In a limited way due to side effects from the require
| function.
|
| Reading that rant, the author did not seem to take much
| time to understand the rationale behind ES modules: " yet
| for some completely unclear reason, ESM proponents
| decided to remove that property" is just pure ignorance.
| jampekka wrote:
| > In a limited way due to side effects from the require
| function.
|
| In a limited way that covers something like 99.9% of CJS
| usage. Bundling is based on static analysis.
|
| If by side effects you mean running code, not just
| declaring exports, ES6 import does these side effects
| too.
|
| Thinking that CJS can't be used for static analysis or
| tree-shaking is the widest spread pure ignorance.
| 9dev wrote:
| The main argument in that post doesn't even hold; using
| ESM, you can always using dynamic imports:
| await import( someExpression )
|
| Besides, static analysis _and_ tree shaking completely
| break down if modules impose side effects from being
| required, which is one of the main gripes of Python as
| well. ESM _completely_ alleviates that.
| jampekka wrote:
| That forces dynamic imports (and is horrible ergonomics
| as discussed in the post). CJS requires can be statically
| analyzed to figure out whether a require can be static,
| as is done e.g. in all bundlers.
|
| ESM import runs side effects as well.
| 9dev wrote:
| We have had top-level await for a while now. I don't know
| what's so horrible about this: const app
| = express() app.use("/users", await
| import("./routers/users"))
|
| ...if you're so inclined to do things _exactly_ as you
| did in the past. I'm pretty sure bundlers will even
| transform that into an ordinary module import.
|
| The cases where you actually need dynamic imports are few
| and far between. Are we actually talking about engine
| limitations here, or is it just a few snowflakes that
| insist on creating loggers like this?
| require("debug")("acme")
|
| What is so particularly pretty about that is beyond me.
|
| > ESM import runs side effects as well.
|
| At runtime, yes. Not during static analysis.
| forty wrote:
| I'm doing server code (which I guess is the main use case
| for nodejs), I just COPY the project folder in the docker
| and ship that. No need for bundling, shaking and other
| annoyance.
| 9dev wrote:
| You'd likely still benefit from using TypeScript and
| transpiling that to plain JavaScript (preventing _a lot_
| of subtle bugs), and tree shaking to minimize your Docker
| image size (yielding faster deployments).
| WorldMaker wrote:
| Tree-shaking also happens in GC heap space (both in the
| server and in the browser): V8 and SpiderMonkey and
| JavaScriptCore will all _eventually_ treeshake unused
| code out of memory entirely. The ESM format was designed
| to allow that. Modules actually only reference each other
| through weak references and things like import * build
| proxy objects of weak references designed for tree-
| shaking.
|
| Depending on how your application is structured, an ESM
| version of a server-only Node application may _still_
| benefit from the performance optimizations of the server
| being able to do dead code elimination at runtime.
| nailer wrote:
| Also not having to debug two separate shipped codebases.
| "The CommonJS version does X" is an annoying github issue
| to fix.
| moltar wrote:
| Annoyingly you have to use an experimental flag. That just adds
| too much friction.
|
| Latest Node version added Node options as config feature. I
| wish that was ported to every version of Node.
|
| Otherwise you have to set NODE_OPTIONS which can often be
| overwritten by some scripts in the execution chain.
| uasi wrote:
| require(esm) is no longer behind a flag in v22.12.0+ and
| v20.19.0+.
| Klaster_1 wrote:
| On a side note, I recently experimented with native TS support in
| Node.js and it felt like magic: no need for extra flags;
| debugging and watch just work; types are simple to re-use between
| browser and server. Erasable syntax seems like the way forward,
| can't wait for it to land in browsers.
| normie3000 wrote:
| > can't wait for it to land in browsers
|
| Presumably for download size and backward compatibility
| everyone will still serve JS to browsers.
| eddd-ddde wrote:
| But it'd be nice not to /have to/. Specially when working on
| new projects with no build step.
| WorldMaker wrote:
| Typescript Types don't add that much to download size and
| generally compress _very well_.
| madeofpalk wrote:
| It is _really nice_. I 'm glad the Node and Typescript teams
| finally starting making pragmatic decisions to actually be
| useful.
|
| Now typescript just needs some better defaults from tsc --init
| to match :)
| Shacklz wrote:
| Same here - it's really quite something. The future is looking
| good!
|
| That being said, there are currently still some hurdles.
| Necessity for explicit file-extensions in the imports is
| definitely the big offender (it's invalid typescript syntax
| without the allowImportingTsExtensions-flag).
|
| The trend is definitely clear though, most devs want ESM, most
| devs want types; it's just a matter of time until the ecosystem
| adapts. I suppose for types to finally land in the browser,
| TC39 will have to undergo the "progress is one funeral at a
| time"-principle, which will probably take another while.
| graypegg wrote:
| Huh, oddly no mention of native browser support in the form of
| <script type="module"> and importmaps! To me, that felt like the
| last platform to offer ESM support.
|
| I also feel like browser support "feels" more official than
| support from bundlers.
|
| Just weird to leave out a mention of that.
| akoboldfrying wrote:
| It's certainly nice, and makes possible Vite's very quick
| reloads of developer code, but even Vite still makes (ESM)
| bundles for performance reasons, both for production (with
| everything, using Rollup) and for dev (with just the external
| deps, using ESBuild).
|
| My understanding is that HTTP 2 pipelining was supposed to make
| requesting lots of ESM modules from the same site fast enough
| that bundling could be consigned to history, but for various
| reasons it didn't work as intended and it was ultimately
| removed from most browsers.
| dieulot wrote:
| HTTP/2 "pipelining" (multiplexing) is still there and works
| as intended; but bundling is still much more efficient.
|
| This article delves into just that:
| https://csswizardry.com/2023/10/the-three-c-concatenate-
| comp... The first pair of waterfall graphics illustrates the
| problem clearly.
|
| (What you vaguely remember as not working as intended is
| probably Server Push).
| chrismorgan wrote:
| HTTP protocol changes were never, ever, going to make
| bundling unnecessary. No one actually involved in the things
| said it would, or thought it would. It was less-deeply-
| involved people misunderstanding things that led to that
| popular impression.
|
| HTTP/2's better multiplexing helps a little, but you've still
| completely got the waterfall problem: so you have at an
| absolute minimum of overhead the dependency graph depth times
| the round-trip time--frequently multiple seconds.
|
| HTTP/2 Server Push could have improved it in some regards, as
| it can _in theory_ break out of the waterfall problem, but in
| practice it would have required much more complex servers,
| and was missing important pieces so that it was completely
| useless anyway (a way for the client to tell the server what
| resources it has cached), and they eventually just removed it
| all round rather than inventing and implementing the missing
| pieces, with which it _still_ would have been a good deal
| less efficient to execute than bundling.
|
| Minifying and bundling is just better, no matter what.
| graypegg wrote:
| Oh yeah I assumed so. I just thought it was weird to not
| mention it in an article about ESM becoming the standard JS
| modules format. Even if it's not the most performant use of
| ES modules, I still think it's certainly the most official
| "signal" of support to have it in all major browsers.
| WorldMaker wrote:
| > My understanding is that HTTP 2 pipelining was supposed to
| make requesting lots of ESM modules from the same site fast
| enough that bundling could be consigned to history, but for
| various reasons it didn't work as intended and it was
| ultimately removed from most browsers.
|
| HTTP/2+ Pipelining works pretty well. (As others mentioned,
| it was HTTP/2 Server Push that didn't quite survive in the
| wild, which could have helped additional scenarios.)
|
| I've been personally moving towards Vite's dev approach for
| _Production_ (just external deps and big libraries with
| esbuild) and little to no bundling in dev (only external deps
| that don 't run out-of-the-box as ESM with an importmap). A
| handful of small "local" files and couple big shared
| libraries works very well in Production in my experience.
| codedokode wrote:
| Sadly ESM don't work with local files (file:// protocol).
| graypegg wrote:
| That would be expected for a browser environment though! I
| don't want any module my browser imports to silently import
| something off the filesystem without telling me.
| WorldMaker wrote:
| Most systems come with Python today, so starting an HTTP
| server for local testing is often a one-liner like `python -m
| http.server`. Anyone already working with Node has access to
| one-liners like `npx http-server`. (Deno and Bun also have
| one-liners.)
| porridgeraisin wrote:
| Any kind of migration breaks code. Every single line of
| _practical_ code hit by a breaking change should be considered a
| bug. Before the security theater takes the stage, let me clarify
| by saying that I don 't mean we should not have breaking changes
| at all, but that only by explicitly treating them as serious
| bugs, can we even hope to set up a process to minimize them. Of
| course, the minimization objective will have multiple other
| variables -- security being one of them.
|
| Therefore, when it is possible to not break _practical_ code, it
| should not be done. This is the "linux philosophy", if you will.
| Among JS runtimes, Bun is a great example. In bun today, you can,
| in the _same file_ (!!), with zero code changes, 100%
| transparently:
|
| 1. require() a module regardless of whether it is cjs or esm [1]
|
| 2. _synchronously_ `import` a module regardless of whether it is
| cjs or esm.
|
| 3. do either of this with typescript or javascript
|
| Some of these required changes in JavascriptCore, which were
| implemented - it's good to see a lack of cribbing about "it needs
| to be supported in upstream" and a focus on practical end-user
| usage.
|
| Any runtime/kernel that doesn't put this level of effort into not
| breaking code is just not serious. While Node is at least moving
| in the right direction, supporting require(esm) with some
| restrictions, Deno is completely hopeless - CJS code is
| completely unusable on Deno. The reasoning is crazy too - how
| does it matter if ESM is the "standard", if millions of lines of
| practical, real world code is using CJS? It does not matter that
| popular libraries move to ESM. Even a single internal tool that's
| locked into CJS for some reason means that a project cannot move
| away from it easily. Then like someone mentioned below, many
| "plugin architectures" and "command architectures" are pretty
| much locked in to require(). The whole Deno project screams
| "ideology > pragmatism". Hopefully, just like they came to their
| senses w.r.t node compat [2], they implement CJS interop as well.
|
| [1] You cannot require() an ESM module that uses top level await
|
| [2] In contrast, bun's node compat story is very good, they run
| the Node.js test suite on every commit to Bun, and pass a huge
| majority of the test cases. Track the progress here:
| https://bun.sh/docs/runtime/nodejs-apis
|
| There is even some effort put into V8 APIs(in a JSC runtime!!).
| This helps with using Bun with the usual debugger in the chrome
| browser/VSCode, modules that use `node:v8`, etc,. Read about it
| here: https://bun.sh/blog/how-bun-supports-v8-apis-without-
| using-v...
|
| There is also compat the _other way_ - there are the beginnings
| of node /browser polyfills for Bun-only APIs:
| https://github.com/oven-sh/bun/tree/main/packages/bun-polyfi...
| shpx wrote:
| VS Code still doesn't support ESM in extensions, you have to
| transpile to CJS.
|
| https://github.com/microsoft/vscode/issues/130367
| mdhb wrote:
| As a side note it was so depressing reading the release notes
| of the latest VS Code. It was all just more and more AI slop
| related nonsense. You could feel the enshitification happening
| in real time. I don't think there was a single new feature in
| there I was even remotely excited about.
| Sloowms wrote:
| ECMAScript modules
| bilalq wrote:
| The way this cutover is being handled is reminiscent of Python
| 2->3. To this day, I still run into compatibility issues between
| things that only work with ESM and things that only work with
| CJS. It's frustrating, to say the least.
| notanaverageman wrote:
| Meanwhile I'm trying to adapt ESM only packages to CJS since Goja
| (JS runtime library for Go) doesn't have ESM support. [1]
|
| And yes I know that Grafana has a fork called Sobek that has ESM
| support, but it is tailored for k6 and they don't have plans for
| making it easier to use. [2]
|
| [1] https://github.com/dop251/goja/issues/348
|
| [2] https://github.com/grafana/sobek/issues/49
| jakub_g wrote:
| Did anyone make a large TypeScript codebase work with ESM + TS +
| node (together with IDE support and yarn pnp support?)
|
| The thing that is annoying with ESM is that it requires to have
| extensions in imports, i.e. `import .. from '/foo.js'`.
|
| This gets messy with TypeScript, where your files are named
| `foo.ts` but you need to import `foo.js`.
|
| The previous "best practice" in TS world was to have
| extensionless JS imports, so this move would require a massive
| codemod to update all imports in an entire codebase.
|
| For now, we've been using `ts-node` with swc under the hood, but
| without ESM. I tried `tsx`, but the compilation time of esbuild
| is way too slow, some of our node CLI tools written in TS take
| 15s to boot up, which is not acceptable (with `ts-node` it's
| 3-4s) (tbh, it's probably partly a fault of our CLI framework,
| which crawls all workspaces to discover all CLI tools, and as we
| have a lot of them, tsx has a lot of useless work to do).
| gauben wrote:
| Hey! Not sure how modern your codebase is, but you can consider
| the following tsconfig settings:
|
| - rewriteRelativeImportExtensions: this will allow you to write
| `import foo from './foo.ts'` and have tsc transform it to
| `import foo from './foo.js'`
|
| - erasableSyntaxOnly: this will error on non "erasable" syntax,
| that is, TypeScript code that has a runtime output (e.g. enums)
|
| With these two settings enabled, you'd be able to run
| TypeScript code directly with Node: `node src/index.ts`, and
| cut boot up time substantially
| hombre_fatal wrote:
| To add, those are the recommended tsconfig.json settings in
| Node's docs on native TS stripping. Here are the rest:
| https://nodejs.org/api/typescript.html#type-stripping
| WorldMaker wrote:
| Also there are eslint rules older than erasableSyntaxOnly
| that can also be useful in doing a "rip-the-bandaid-off-
| refactor" using all the lint warnings/warnings-as-errors to
| add extensions everywhere in the case where your brownfield
| also needs to be a version or two behind on Typescript.
| paulddraper wrote:
| It's pretty easy to find/replace everywhere that needs it.
|
| In my experience the reason people don't is that it offends
| their aesthetics.
|
| Which I understand. But personally I don't program for
| aesthetics.
| theogravity wrote:
| I use a custom loader to deal with this:
|
| https://github.com/theogravity/fastify-starter-turbo-monorep...
|
| Usage:
|
| Search for loader.js refs.
|
| https://github.com/theogravity/fastify-starter-turbo-monorep...
| sarreph wrote:
| > Although a significant portion of packages still use CJS, the
| trend clearly shows a good shift towards ESM.
|
| Does it?
|
| The chart being used in the opening argument is far from
| compelling. When I hold my phone sideways, it looks as though the
| ESM portion is plateauing or feebly (at best) increasing. Maybe
| by 2040 we'd see widespread ESM adoption?
|
| Jokes aside, I prefer ESM (and would prefer if everyone else
| preferred it), but leading with adoption rates is weak sauce.
| codedokode wrote:
| I am surprised they still use outdated syntax when there is a
| standard for modules. Also, it is much better when you specify
| full path to the file and do not have to remember the resolving
| rules.
|
| Also, last time I tried to use ESM in a browser with Vue and
| unpkg without builders and web server, it didn't work (probably
| because one cannot import modules from a local folder). I wish
| support for ESM and localhost was improved also. I don't want to
| use a webserver or a bundler because it is easier without them so
| I switched to legacy scripts without modules.
| tpm wrote:
| > one cannot import modules from a local folder
|
| Pretty sure this works and it is indeed possible to use ES
| modules without builders and a webserver, I recently rewrote a
| Node app this way (with Claude's help even because I'm a ES
| noob). No idea about Vue and unpkg though, there could be an
| issue with that.
|
| edit: eh you mean like "import 'file://module.js'", but I don't
| understand why would you need that when you can use "import
| './module.js'"
| codedokode wrote:
| Doesn't work in a browser [1]:
|
| > file: URLs are supported by many non-browser runtimes such
| as Node, since scripts there already have file: URLs, but
| they are not supported by browsers due to security reasons.
|
| If you open HTML file directly in a browser, it cannot import
| ES modules from a filesystem. It can import legacy scripts,
| but they, as I understand, cannot import anything at all.
|
| [1] https://developer.mozilla.org/en-
| US/docs/Web/JavaScript/Refe...
| tpm wrote:
| You don't need file:// module URLs when you say open a
| index.html from your local drive through
| file://C:/index.html but then in that index.html you have a
| 'script type="module" src="src/index.js"' and from there
| you go through usual relative paths without protocol.
|
| This works in Firefox, I have one project open like that
| right now. Chrome blocks that because of their CORS policy,
| but that's another issue, can be mitigated by some
| commandline options (--allow-file-access-from-files --cors-
| exempt-headers "*")
| matt_kantor wrote:
| > Chrome blocks that because of their CORS policy
|
| Firefox has the same behavior (I just tested using
| Firefox 136 on macOS). The error message says "CORS
| request not HTTP"[0]. You might have disabled
| `security.fileuri.strict_origin_policy` in about:config?
|
| [0]: https://developer.mozilla.org/en-
| US/docs/Web/HTTP/Guides/COR...
| tpm wrote:
| Yeah, happened long ago so I didn't remember it. Still
| for dev env I think it's a reasonable tradeoff.
| WorldMaker wrote:
| While you can't use it without a webserver, today that
| webserver can be a one-liner like `npx http-server` or `python
| -m http.server` at least.
| plopz wrote:
| The main problem with es modules is mocking in tests. How do
| people work around that issue?
| codedokode wrote:
| You don't use modules as a replacement for DI?
| giorgioz wrote:
| You either use Vitest for new projects or you use
| jest.unstable_mockModule for old Jest tests
| WilcoKruijer wrote:
| Define an interface, mock the implementation. How people
| outside of JS/TS have been doing it for years.
| patwolf wrote:
| I was working on a legacy CSJ project, and I tried to upgrade an
| OIDC library, but the newer version would only work with ESM. I
| decided to use that as an excuse to migrate our project to ESM.
| However, I hit a bug using dd-trace with ESM. Over a year later,
| that bug hasn't been resolved. I try to use ESM as much as
| possible for new projects, but it's not always simple to migrate
| existing projects to ESM.
| darepublic wrote:
| All these new bundling libraries (vite/rollup) always claim to
| just work, chefs kiss, 100 emote. And then you actually try to
| use them for basic use cases and you find yourself googling or
| knee deep in the documentation. If at all possible I try to avoid
| bundling libs altogether. That's the future I want. No more vite
| or rollup or webpack.
| thiht wrote:
| Uh? That's completely alien to my experience with Vite. Setting
| up a Vite/React/Typescript project with Tailwind/Tanstack
| Query/Tanstack Router is an absolute breeze, you just follow
| the docs. Adding ESLint/Prettier is also just following the
| docs.
|
| What problems did you encounter specifically? Did you report
| them?
| winrid wrote:
| Vite is probably the only one that just works? If you follow
| the first 3mins of Getting Started it works great.
| bhouston wrote:
| I've been ESM only for the last 2 years and it has been amazing.
| Now to be fair, I am doing it on new projects so that probably
| simplifies things, but it is very freeing and performant.
___________________________________________________________________
(page generated 2025-03-24 23:02 UTC)