Libraries are shifting to ESM-only, build tools reveal sharp edges, and mixed ecosystems introduce subtle incompatibilities. This post offers a technically grounded snapshot of challenges and emerging patterns during this transitional phase, based on my hands-on experience and community input.
How We Got Here
JavaScript’s module story has been… complicated. In the early days, all JavaScript code lived in the global scope. Developers relied on naming conventions and immediately invoked function expressions (IIFEs) to prevent collisions. Then Node.js came along with CommonJS, giving us require() and module.exports. Finally, in 2015, ECMAScript 6 introduced native modules with import and export statements.
Fast forward to today, and we’re living in a mixed module world. Browsers have standardized on ESM, while Node.js has been gradually improving its ESM support since version 12. The promise is enticing: a unified module system that works everywhere. The reality? Let’s just say it’s been an adventure.
The Dependency Chain of Pain
Last month, I was updating dependencies for a client project when everything suddenly broke. A seemingly innocent patch update to a utility library three levels deep in our dependency tree. This library had decided to go “ESM-only,” which ordinarily wouldn’t be a problem—except that one of its consumers was strictly CommonJS, creating an incompatibility that bubbled up to our application.
This kind of “module impedance mismatch” has become increasingly common as more libraries make the switch. Your project might be ready for ESM, but what about your dependencies? And their dependencies? It’s turtles all the way down.
After an afternoon of frustration, I ended up forking the problematic dependency to create a dual-package version. Not elegant, but it worked. This pattern of hand-patching dependencies or pinning to older versions has unfortunately become a common workaround in many projects.
Jest: The Testing Ground for Module Patience
If you’ve tried to use Jest with ESM, you’ve probably experienced some pain. Jest itself is written in CommonJS, which creates some interesting challenges when testing ESM code.
Just last week, I was setting up tests for a new project and spent hours trying to get Jest working with my ESM codebase. The solution involved a complex dance of configuration settings:
// package.json
{
"type": "module",
"jest": {
"transform": {},
"extensionsToTreatAsEsm": [".js"],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}
}
}
And that’s just the beginning. Once you add TypeScript to the mix, things get even more interesting. I’ve had more success with Vitest lately, which was built with ESM support in mind, but migrating existing test suites isn’t always practical.
The “type”: “module” Toggle Switch
One of the most powerful yet confusing aspects of the ESM transition is the humble "type": "module" field in package.json. Add this line, and suddenly all your .js files are interpreted as ES modules. Remove it, and they’re back to CommonJS.
This leads to a situation I call “module whiplash.” I’ve watched developers (including myself) toggle this setting back and forth as they encounter various compatibility issues, often without fully understanding the implications:
# A common progression I've observed in git histories:
- Add "type": "module"
- Fix import paths to include .js extensions
- Run into problems with __dirname not being defined
- Add workarounds for missing CommonJS globals
- Encounter a library that doesn't work with ESM
- Remove "type": "module"
- Fix all the import paths again
- Add back "type": "module" but with more workarounds
The funny part is that after all this toggling, many projects end up with a hybrid approach: ESM for application code, with careful boundaries around CommonJS dependencies.
The Build Tool Revolution
Last year, I wrote about how Go and Rust were bringing unprecedented speed to JavaScript tooling. That trend has continued to reshape how we approach module bundling and transformation. Tools like esbuild (written in Go) have dramatically accelerated build times, making the development experience with ESM more bearable.
In my daily work, I’ve largely switched from webpack configurations that took minutes to complete to esbuild-powered setups that finish in seconds. This speed has been a game-changer when working with ESM, as the faster feedback loop helps catch module-related issues earlier.
I’ve also been impressed with how SWC has matured over the last year. It’s now integrated into Next.js by default, replacing Babel for transpilation. For projects that need to support both ESM and CommonJS, these newer tools provide much more efficient transformation paths.
However, the transition hasn’t been entirely smooth. While these new tools excel at speed, they sometimes lack the full feature set of their JavaScript predecessors. I’ve occasionally had to maintain parallel configurations—using esbuild for development and webpack for production builds—when requiring advanced optimizations or specific plugins.
The Browser-Node Divide
What works in browsers doesn’t always work in Node.js, and vice versa. This has always been true to some extent, but the module transition has highlighted these differences.
For example, in browsers, you can use a bare import like:
import { something } from 'some-package';
But in Node.js ESM, you’ll often see URLs with explicit file extensions:
import { something } from './utils.js';
This inconsistency has led to many tools generating different output for browser versus Node.js environments, adding complexity to build pipelines.
I recently had to maintain two separate entry points for a library: one for browsers (with path mapping handled by bundlers) and one for Node.js (with explicit file extensions). It works, but it feels like we’ve added accidental complexity to what should be a straightforward standard.
What’s Working: Practical Patterns
Despite the challenges, several patterns have emerged to make the transition more manageable. The dual package approach has gained traction among library authors, allowing packages to work in both ESM and CommonJS environments through clever use of the exports field in package.json. When publishing libraries myself, I’ve found that build-time transpilation with tools like esbuild, Rollup, or SWC creates flexibility to write in modern ESM while distributing in multiple formats.
For application development, I’ve seen a clear divide forming. Projects built with newer tools like Vite that embrace ESM from the ground up tend to have a smoother experience than those retrofitting ESM into webpack-based setups. There’s also the file extension strategy—using explicit .mjs for ESM and .cjs for CommonJS files—which sidesteps many configuration headaches at the cost of more verbose filenames. I was initially resistant to this approach, but after repeatedly dealing with configuration issues, I’ve come to appreciate its clarity.
Where We’re Headed
The transition to ESM hasn’t been as smooth as many of us hoped, but progress is being made. Node.js’s ESM implementation continues to mature, tooling is improving, and more libraries are supporting both module systems or moving to ESM entirely.
As someone who builds for both browsers and Node.js, I’m cautiously optimistic. The future where we have a single, unified module system that works everywhere still seems achievable, even if the path there has been rockier than expected.
There’s a quote that’s been making the rounds in developer circles, which captures the sentiment well: “ESM is the future of JavaScript, and always will be.” It’s a bit cynical, but it contains a kernel of truth—the transition is taking longer than many expected, but the direction is clear.
ESM and Modern Tooling
The rise of ESLint plugins specific to ESM has also been helpful in navigating this transition. Tools for statically analyzing imports and ensuring consistency across module boundaries have become essential, especially in larger codebases where different parts might be at different stages of the migration.
One pattern I’ve found particularly useful is explicit import delineation. By setting up ESLint rules that enforce how different kinds of imports are organized—external packages first, then internal absolute imports, followed by relative imports—we can make module boundaries more visible and maintainable. This has helped catch subtle ESM-related issues early.
Lessons Learned
My journey through the ESM transition has taught me the value of pragmatism above all else. There’s no need to go all-in on ESM if your ecosystem isn’t ready—incremental approaches often yield better results with less frustration. I’ve found that investing time in understanding and optimizing build pipelines pays dividends, as good tooling can abstract away many of the inconsistencies between environments.
Cross-environment testing has saved me countless hours of debugging production issues. What works perfectly in your development setup might break spectacularly when deployed if the module assumptions differ even slightly. Perhaps most importantly, I’ve learned to stay informed while not chasing every trend. The JavaScript ecosystem is still evolving rapidly, and approaches that seem promising today might be obsolete tomorrow. A measured, thoughtful approach to adoption has consistently outperformed hasty migrations in my experience.