Navigating the CommonJS-to-ESM Transition in Node.js: Pain Points, Progress, and Best Practices

By hungpd, at: Aug. 21, 2025, 3:29 p.m.

Estimated Reading Time: __READING_TIME__ minutes

Navigating the CommonJS-to-ESM Transition in Node.js: Pain Points, Progress, and Best Practices
Navigating the CommonJS-to-ESM Transition in Node.js: Pain Points, Progress, and Best Practices

1. Introduction: The Module Wars

 

Over a decade, Node.js developers have lived in a CommonJS world. Every require('fs') and module.exports = foo defined the DNA of backend JavaScript. But in 2015, the ECMAScript committee introduced ES Modules (ESM) with import and export, setting a universal standard for both browsers and runtimes.

 

Fast forward to Node.js 24: ESM is not just an experiment as it’s the default direction. Frameworks like Next.js, Remix, and Astro are already “ESM-first,” while package maintainers are dropping CommonJS support to reduce maintenance costs.

 

And yet… many developers still grumble. Why? Because migrating an ecosystem as large as npm (over 2.5M packages) is like turning a cargo ship, slow, painful, and full of edge cases.

 

2. Why This Transition Feels Painful

 

Syntax Differences

 

  • CommonJS:

const fs = require('fs');
module.exports = function hello() { return 'world'; }

 

  • ESM:

import fs from 'node:fs';
export default function hello() { return 'world'; }

 

This may look cosmetic, but the implications (static vs. dynamic resolution, hoisting, async import) are deep.

 

File Extensions & "type": "module" Confusion

 

  • .cjs → Always CommonJS
     

  • .mjs → Always ESM
     

  • .js → Depends on package.json "type" field
     

    This tripped up countless teams, one file suddenly breaks because the wrong mode was inferred.

 

Ecosystem Lag

 

  • Some critical libraries (e.g., older express middlewares) were CJS-only until recently.
     

  • Many devs stick to CJS to avoid tooling issues with Jest, Webpack, or Mocha.

 

3. Recent Node.js Improvements (v22 → v24)

 

  • Interop Upgrades:

    Node 22 introduced the ability to require() entire ESM module graphs from CommonJS, removing one of the biggest blockers.
     

  • Better Error Messages:

    Instead of cryptic “ERR_REQUIRE_ESM,” Node now tells you why a module can’t be imported and suggests fixes.
     

  • Import Attributes:

    Node 24 (V8 13.6) supports import assertions natively:
     

import data from './data.json' assert { type: 'json' };

 

  • No need for loaders or experimental flags.
     

  • Framework Pressure:

    Next.js and others only ship ESM builds now. If you’re not on board, you’re stuck.

 

4. Migration in Practice: Scenarios

 

4.1 Small Project Migration

 

  1. Add "type": "module" to package.json.
     

  2. Update imports:

     

    • require('foo') → import foo from 'foo'
       

    • module.exports = → export default

 

Before (CJS):

 

const moment = require('moment');
module.exports = () => moment().format();

 

After (ESM):

 

import moment from 'moment';
export default () => moment().format();

 

4.2 Supporting Both Worlds (Library Authors)

 

Use conditional exports:

 

{
  "exports": {
    "import": "./esm/index.js",
    "require": "./cjs/index.cjs"
  }
}

 

This way, ESM users get modern code, while legacy apps still run on CJS.

 

4.3 Legacy Dependencies

 

For packages that still don’t support ESM, Node offers createRequire:

 

import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const legacy = require('old-package');

 

5. Benefits vs. Drawbacks

 

Aspect ESM Benefits Challenges / Trade-offs
Standardization Same module system in browsers & Node Dual support adds complexity for libs
Performance Static analysis enables tree-shaking & faster builds Cold-start penalty in some cases (async imports)
DX Cleaner syntax; async import() Confusion with .cjs, .mjs, and "type"
Ecosystem Modern frameworks are ESM-first Legacy packages may never migrate

 

6. Best Practices

 

  1. New Projects → Always ESM ("type": "module").
     

  2. Libraries → Dual Export until CJS fades further.
     

  3. Apps → Migrate Gradually: start with leaf modules, not core entry points.
     

  4. Tooling Check: Ensure your bundler/test framework has full ESM support.
     

  5. Educate Teams: Document patterns like import.meta.url and createRequire.

 

 

7. Conclusion

 

The CommonJS-to-ESM shift is more cultural than technical. Many developers feel “migration fatigue,” but in 2025, resisting ESM is like refusing to use Git in 2010, you’ll be left behind.

 

The Node.js team has done its part: better interop, fewer flags, clearer errors. Now it’s on developers and maintainers to embrace the standard.

 

Takeaway: Start small, migrate gradually, and you’ll unlock a cleaner, faster, and more future-proof codebase.

Tag list:

Subscribe

Subscribe to our newsletter and never miss out lastest news.