The Developer's Quarterly · JavaScript ModulesVol. I · January 2026

CommonJS vs ESM:How JavaScript Modules Actually Differ

CommonJS and ESM solve the same module problem, but they differ in syntax, loading semantics, runtime behavior, and how tooling can optimize them.

If you work with JavaScript long enough, you eventually hit the split between CommonJS and ESM. Both solve the same broad problem, organizing code into modules, but they do it with different syntax, different loading semantics, and different runtime assumptions.

At a surface level, the distinction looks simple: CommonJS uses require() and module.exports, while ESM uses import and export. In practice, the real differences are deeper. They affect when modules are evaluated, whether imports are static or dynamic, how tooling performs optimization, and how Node.js resolves files at runtime.

CommonJS in one sentence

CommonJS is the older Node.js module system. It was designed for synchronous loading and server-side execution, which is why importing a module looks like a function call.

CommonJS import
const math = require('./math'); console.log(math.sum(2, 3));
CommonJS export
function sum(a, b) { return a + b; } module.exports = { sum };

This model works well in Node because files are available locally and can be loaded synchronously.

ESM in one sentence

ESM, short for ECMAScript Modules, is the standard JavaScript module system. It is part of the language itself and is supported in browsers and Node.js.

ESM import
import { sum } from './math.js'; console.log(sum(2, 3));
ESM export
export function sum(a, b) { return a + b; }

Because import and export are part of the language grammar, the module graph can be analyzed before execution. That one design choice explains many of the practical differences between ESM and CommonJS.

Static imports vs runtime requires

The biggest technical difference is that ESM imports are static, while CommonJS imports are runtime expressions.

Dimension CommonJS ESM
Import style require() call executed at runtime import declaration analyzed before execution
Export style module.exports object-based export export declarations with live bindings
Loading model Synchronous by design Linked before evaluation
Tooling analysis Harder because dependencies can be dynamic Easier because imports are static
Dynamic CommonJS require
const moduleName = process.env.DEBUG ? './debug' : './prod'; const logger = require(moduleName);

The module to load is decided while the code is running. That flexibility is powerful, but it also makes the dependency graph harder to analyze ahead of time.

Dynamic ESM import
const loggerModule = await import('./logger.js'); loggerModule.logger('ready');

ESM keeps static imports for the normal path and uses import() when loading must be deferred to runtime.

Execution model and evaluation timing

1 CommonJS executes immediately

When Node hits require(), it resolves the file, runs it, caches it, and returns the exported value.

2 ESM links before evaluation

The runtime first resolves the module graph, then links it, then evaluates it with stricter ordering rules.

3 Top-level await fits ESM naturally

Because the module system is formalized, ESM can support features that do not fit well into classic synchronous CommonJS loading.

Top-level await in ESM
const config = await fetchConfig(); export default config;

Live bindings vs exported objects

ESM exports are live bindings. If the exporting module updates a binding, the importing module sees the updated value.

Live binding example
export let counter = 0; export function increment() { counter += 1; }
ESM consumer
import { counter, increment } from './counter.js'; console.log(counter); // 0 increment(); console.log(counter); // 1

CommonJS behaves differently because consumers receive the exported object returned by require(). Mutable state can still be shared, but the semantics are object-based rather than language-level live bindings.

Default interop confusion

One of the most common sources of confusion is interop between the two systems.

CommonJS default-style export
module.exports = function createServer() { return {}; };
ESM consumer
import createServer from './server.cjs';

That can work depending on the runtime and tooling, but the exact interop behavior is not identical across Node.js, Babel, and bundlers. This is why mixed codebases often end up with confusing edge cases around default, named exports, and transpiled helper code.

Resolution rules in Node.js

Node treats the two systems differently during resolution. Module format is not only about syntax. It is also about package-level configuration.

package.json module type
{ "type": "module" }
CommonJS defaults Historically relied on Node resolution behavior and looser implicit patterns.
ESM file semantics Usually expects clearer file interpretation and explicit extensions in many Node flows.
Package fields matter type, main, and exports influence how files are resolved.
Format is contextual The same .js extension can mean different things depending on package configuration.

Tooling and tree shaking

Bundlers prefer ESM because static imports make dependency analysis easier. Tree shaking works best when unused exports can be identified without executing code.

Tree-shakeable ESM exports
export function used() { return 'used'; } export function unused() { return 'unused'; }

If only used is imported, a bundler can often eliminate unused from production output. That is much harder with CommonJS because require() is dynamic and export shapes can be assembled at runtime.

When each one still makes sense

Use case Better fit
Older Node.js codebase with broad package compatibility needs CommonJS
New app using modern bundlers and browser-compatible modules ESM
Need for top-level await and cleaner static analysis ESM
Legacy server scripts built around synchronous require flows CommonJS

CommonJS is not obsolete, but ESM is usually the better default for new JavaScript projects because it is the standard format and aligns better with modern tooling.

Bottom line

CommonJS and ESM both organize JavaScript into modules, but they come from different eras and different assumptions. CommonJS is runtime-driven, synchronous, and Node-first. ESM is standard, statically analyzable, and better aligned with modern tooling.

If you understand that difference, most of the confusing syntax and interop issues start to make sense.