So, CommonJS versus ES modules. Which of the differences actually bite you in production?
A strong answer treats CJS and ESM as two different load models, sync execution versus parse-link-evaluate, and says where that gap actually bites in prod
So CommonJS is require and module.exports, and it loads synchronously at runtime. ES modules are import and export, and those are static, which is what gets you tree-shaking and top-level await and that kind of thing. Node decides which format a file is from the nearest package.json "type" field, or from the .mjs and .cjs extensions. In practice the differences I've run into are, __dirname and __filename just don't exist in ESM, you have to derive them from import.meta.url. Interop is a little one-directional too, import can load CommonJS fine, but require loading ESM needs care, I think. And if you want conditional or dynamic loading in ESM you go through import(), which is async. ESM is the standard going forward though, most new packages ship it, so migration is mostly mechanical really. Update the syntax, fix the dirname thing, set "type": "module".
Every difference that actually hurts comes from the same root, the two formats load completely differently. CJS is just an API. require resolves the file, runs it right there at the call site, synchronously, and hands you whatever module.exports happens to hold at that moment. Stick a require inside an if branch and it runs lazily, or never. ESM is a language feature and it runs in three phases. The whole graph gets parsed first, which is why syntax errors show up before any code runs. Then linking, where imports and exports get wired up as bindings, that's why cycles resolve more gracefully. Evaluation comes last. Two production things fall straight out of that. Hoisting is the first. Every static import gets resolved and evaluated before a single statement in your file runs, doesn't matter where you put the import line, so side-effect ordering you used to control with statement position in CJS just quietly changes. Second is live bindings. An ESM import is a view onto the exporting module's binding, reassign the export and every importer sees the new value. A CJS consumer holds whatever was sitting on the exports object when it called require. Then you've got the environment gaps, __dirname and __filename versus import.meta.url, plus import.meta.dirname on current Node, require.cache versus no public cache handle on the ESM side, strict mode always on, top-level this being undefined instead of module.exports. And resolution gets stricter. Relative imports need real file extensions and an explicit /index.js, because ESM doesn't go probing the filesystem the way CJS extension-guessing did.
I rank these by what they cost in incidents. Startup-order regressions sit at the top. You take a CJS service that required config, then set up instrumentation, then connected to things, and after migration it's an ESM graph where every static import evaluates before the first statement of your entry file. So the APM agent comes up after the http module it was supposed to patch. Or a pool module reads process.env before config loads. These land as works locally, broken metrics in prod, and they take days to attribute. Second is the dual-package hazard. A dependency ships both formats, some old transitive dependency pulls it in with require, your code pulls it with import, and now it's loaded twice. Module-level state forks into two registries, instanceof fails across the boundary, your singletons aren't singletons anymore. Third, deep imports into a package that added an exports map in a minor release. Install goes fine, then at runtime you get ERR_PACKAGE_PATH_NOT_EXPORTED. So I treat a format migration as a deploy-risk change and plan it like one. I inventory which dependencies get consumed by which format, --trace-require-module=all will print every require(esm) path for you. I put a smoke boot in CI that asserts init order off a startup log line per subsystem. Env loading comes out of module code entirely, either --env-file or platform injection. And I ban module-level side effects that depend on other modules' side effects, because that's the actual root cause under every one of these ordering incidents. Where I land, one format per service if you can manage it, interop confined to a known boundary file, and require(esm) on modern Node as the escape hatch that lets a CJS codebase consume ESM-only dependencies without a rewrite.
- A lot of people recite 'ESM is static, CJS is dynamic, ESM enables tree-shaking' and call it a day. Those are bundler talking points, they say nothing about how Node loads either format.
- Some candidates think the __dirname gap is the hard part of a migration, mostly because it's the first error the tutorial threw at them.
- You'll also hear people who can't explain why their dotenv config stopped applying after the migration. Import hoisting never made it into their mental model.