The module wars are mostly over, but the battlefield left scars. Node has supported ESM natively for years now, most major frameworks default to it, and yet dual-package hazards, half-migrated dependency trees, and require() of ESM still show up in real incident reports. If you're starting a new package or app in 2026, the decision isn't "which format is better" — it's "which format minimizes the number of ways your users can shoot themselves in the foot."
The dual-package hazard hasn't gone away
When a package ships both a CJS and an ESM build, Node resolves them independently based on how the consumer imports it. If your library keeps module-level state (a cache, a singleton, a registry), a project that pulls in both entry points — directly and transitively through a different dependency — ends up with two disconnected copies of that state. This is still the single most common cause of "it works in isolation but not in my app" bug reports for library authors who ship dual builds.
// package.json for a dual-format library
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs"
}If your library has no shared mutable state across the two builds, dual-publishing is safe. If it does — even something as innocuous as a WeakMap cache — pick one format and stop trying to please everyone.
Interop still has sharp edges
require() of an ES module works in current Node for synchronous, side-effect-free modules, but it fails outright the moment the module has top-level await or certain circular dependency shapes. Going the other direction — import of a CommonJS module from ESM — is more forgiving: Node synthesizes a default export and does its best with named exports via static analysis, but that analysis isn't perfect for dynamically assigned module.exports properties.
// Works: importing CJS from ESM
import pkg from "legacy-cjs-package";
const { doThing } = pkg;
// Fragile: named import depends on static analysis succeeding
import { doThing } from "legacy-cjs-package"; // may be undefinedThe safe default when consuming an older CJS dependency from ESM code is to import the default and destructure manually, rather than trusting named imports to resolve correctly.
Bundlers still disagree with Node
Webpack, esbuild, and Rollup each have their own interop shims for CJS/ESM boundaries, and they don't all match Node's runtime behavior exactly — particularly around __esModule interop flags and how a CJS module's exports.default gets treated. Code that behaves identically under node and under your bundler's dev server can diverge once it hits a build step with different interop settings. If you maintain a library, test it under at least Node's native loader and whatever bundler your primary consumers use — don't assume one implies the other.
What to actually do for a new project
For a new app: use "type": "module" and don't look back. Nearly every actively maintained dependency has an ESM path in 2026, and the ergonomic wins (top-level await, static analysis for tree-shaking, no __dirname weirdness once you adopt import.meta.url) are worth it.
For a new library aimed at broad consumption: publish ESM-only unless you have concrete evidence a meaningful slice of your users are stuck on old CJS-only tooling. Dual-publishing is a maintenance tax and a hazard-generator; only pay it when you've measured the demand, not preemptively.