After five and a half years building production systems, I've seen the module system choice ripple through entire codebases. ES modules vs CommonJS isn't just about syntax—it's about whether your bundler can tree-shake dead code, whether you can use top-level await, and whether you'll spend three hours debugging a default export mismatch at 2 AM. Let me walk you through both systems, the real pain points, and why you should default to ESM in 2024.
CommonJS — require and module.exports. Synchronous and dynamic.
CommonJS is the module system Node.js shipped with. It's been the standard for over a decade, and it's synchronous—meaning when you call require(), the module loads immediately, the code runs, and exports are assigned right then.
// math.js (CommonJS)
module.exports = {
add: (a, b) => a + b,
multiply: (a, b) => a * b
};
// app.js (CommonJS)
const math = require('./math');
console.log(math.add(2, 3)); // 5
That synchronous nature felt natural when Node.js was born. You could require modules in a loop, conditionally, or even inside functions. The exports object was built up as the module ran, and you could inspect it at any point.
// Conditional require (works in CommonJS)
let config;
if (process.env.NODE_ENV === 'production') {
config = require('./config.prod');
} else {
config = require('./config.dev');
}
// Dynamic require (works in CommonJS)
const moduleName = 'utils';
const utils = require(`./${moduleName}`);
This flexibility was powerful. But it came with a cost: static analysis was nearly impossible. A bundler couldn't look at your code and know which modules you actually needed. It had to assume everything was live, or execute your code to figure out dependencies. That meant tree-shaking—removing unused code—was unreliable or impossible with CommonJS.
I ran into this on a trading platform where we had a 2.2 MB bundle with dozens of indicator calculations. We wrote the code to conditionally require them based on user settings. Great for flexibility in Node.js; terrible for bundle size in the browser. We couldn't shake the unused ones.
ES modules — import and export. Static, analyzable and tree-shakeable.
ES modules (ESM) were standardized in ES2015 (ES6) and solve the static analysis problem. When you write import statements, they're declarations, not runtime function calls. A bundler can read your code without executing it and know exactly what you depend on.
// math.ts (ES modules)
export const add = (a: number, b: number): number => a + b;
export const multiply = (a: number, b: number): number => a * b;
// app.ts (ES modules)
import { add, multiply } from './math';
console.log(add(2, 3)); // 5
Because imports are static, you get several immediate wins:
- Tree-shaking: If you only import
add, the bundler removesmultiplyfrom your final bundle. - Dead code elimination: Tools like webpack, esbuild, and Vite analyze the import graph before building, so unused modules vanish entirely.
- Circular dependency detection: Static analysis makes it easier to spot when module A imports B imports C imports A.
- Top-level await: You can await at the module level—something CommonJS can't do.
// config.ts (ES modules with top-level await)
const dbConnection = await fetch('/config/db');
export const DB_URL = await dbConnection.json();
// app.ts
import { DB_URL } from './config'; // Waits for config to load
console.log(DB_URL);
On a CRM project I worked on, switching from CommonJS to ESM cut our client bundle from 180 KB to 120 KB just from tree-shaking. The remaining code was genuinely used.
There's also import * (namespace imports) and default exports, which give you flexibility while keeping the structure analyzable:
// utils.ts
export const formatDate = (date: Date): string => date.toISOString();
export const formatCurrency = (amount: number): string => `$${amount.toFixed(2)}`;
// app.ts (named imports)
import { formatDate, formatCurrency } from './utils';
// Or namespace import
import * as utils from './utils';
console.log(utils.formatDate(new Date()));
console.log(utils.formatCurrency(99.99));
Interop pain — Mixing them causes default export confusion.
Here's where it gets messy. The JavaScript ecosystem still has CommonJS packages (especially older ones or pure Node.js libraries). When you try to import a CommonJS module from ESM, or vice versa, the module systems don't translate perfectly.
The biggest gotcha is the default export problem.
// math.js (CommonJS)
module.exports = {
add: (a, b) => a + b
};
// In ESM, you might try:
import math from './math'; // Seems right
console.log(math.add(2, 3)); // Works!
// But a bundler or Node.js might treat module.exports as the default OR as a namespace.
// This inconsistency is the pain point.
When Node.js loads CommonJS via ESM, it wraps the module.exports object and exposes it as the default export. But not all bundlers do this the same way. I've debugged this on a ride-hailing platform where we had a legacy CommonJS auth library. Some developers imported it as default, others as namespace, and it worked inconsistently across dev, test, and production environments.
The real issue: CommonJS is dynamic and ESM is static. You can't perfectly map one to the other without runtime trickery.
// Better approach: Use named exports in CommonJS
// math.js
module.exports.add = (a, b) => a + b;
module.exports.multiply = (a, b) => a * b;
// ESM can import these as named exports
import { add, multiply } from './math'; // Cleaner
Or explicitly handle the CommonJS default:
// If using a package like Express (CommonJS):
import express from 'express'; // Default export works here because Node.js handles it
// But for custom CommonJS, consider:
import { createServer } from 'http'; // Named exports are safer
Default to ESM — type: module in package.json for new projects.
If you're starting a new project—a SaaS platform, a CLI tool, a backend API, or a library—use ES modules. Node.js has supported them natively since v12, and the ecosystem has matured.
To enable ESM in Node.js, add to your package.json:
{
"name": "my-app",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
Now all .js files are treated as ESM. If you need CommonJS, use .cjs explicitly:
// app.js (ESM)
import { serve } from './server';
// legacy.cjs (CommonJS)
const oldLib = require('some-old-package');
The "exports" field lets you specify different entry points for ESM and CommonJS consumers. This is crucial if you're building a library that needs to support both.
I've standardized on ESM across my last three major projects—a travel booking platform, a SaaS analytics tool, and an internal trading system. The benefits were consistent:
- Smaller bundles (tree-shaking works reliably).
- Faster builds (static analysis is faster than dynamic inspection).
- Cleaner code (imports are explicit, no "magic" global requires).
- Future-proof (ESM is the JavaScript standard, CommonJS is legacy).
Common mistakes
Mistake 1: Mixing default and named exports carelessly.
This works but confuses readers:
// utils.ts
export const helper = () => {};
export default class Processor {}
// app.ts
import Processor, { helper } from './utils'; // Mixing styles
Stick to one pattern per file. Use named exports for utilities, default export for classes or main functions.
Mistake 2: Assuming CommonJS interop "just works."
It doesn't always. If you're importing a CommonJS package into ESM, test it explicitly. Document the import style.
Mistake 3: Leaving CommonJS code in a new ESM project.
I've seen codebases where type: module is set but devs still write const x = require() by accident. This breaks silently or with confusing errors. Use a linter (ESLint) to enforce ESM syntax.
// .eslintrc.json
{
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"no-var": "error",
"prefer-const": "error"
}
}
Mistake 4: Not setting up exports in package.json for libraries.
If you're publishing a package, define the "exports" field clearly. Otherwise, consumers won't know whether to use ESM or CommonJS, and bundlers will guess wrong.
Key takeaways
- CommonJS is synchronous and dynamic. It's useful for server-side code that needs runtime flexibility, but it prevents bundlers from tree-shaking unused code.
- ES modules are static and analyzable. They enable tree-shaking, faster builds, and are the JavaScript standard going forward.
- Mixing them causes subtle bugs. Default export handling differs between CommonJS and ESM. When you must interop, be explicit and test thoroughly.
- New projects should use ESM. Set
"type": "module"in package.json, use named imports, and avoid CommonJS syntax. - Legacy projects may stay CommonJS. If you maintain older Node.js libraries, CommonJS is still valid. Just document which module system you support.
- Libraries should expose both. Use the
"exports"field to support ESM and CommonJS consumers, building to both formats if needed.






Comments
Loading comments…