CommonJS vs ES Modules in Node.js

The difference between CommonJS (CJS) and ECMAScript Modules (ESM) is much deeper than require() versus import/export.

They use different loaders, dependency models, execution semantics, resolution rules, caches, and interoperability behavior.

One-line difference

CommonJS is a synchronous, runtime module system built for Node.js. ES modules are a language-level standard with a statically analyzable dependency graph that is linked before execution.

Quick comparison

CommonJSES modules
Syntaxrequire(), module.exportsimport, export
OriginCreated for server-side JavaScript and adopted by Node.jsStandardized as part of ECMAScript
Loading modelSynchronousSupports asynchronous module graphs
Dependency discoveryWhile code executesStatic imports are discovered before execution
ImportsValues returned from module.exportsRead-only live bindings to exports
Conditional loadingrequire() can be called inside normal control flowUse dynamic import() for runtime loading
Top-level awaitNot supportedSupported
File identityPrimarily resolved filenamesURLs, normally file: URLs in Node.js
Cacherequire.cacheSeparate ESM loader cache

CommonJS: load and execute on demand

// math.cjs
module.exports = {
  add(a, b) {
    return a + b;
  },
};
 
// app.cjs
const math = require("./math.cjs");
console.log(math.add(2, 3));

When Node reaches require("./math.cjs"), it:

  1. resolves the module,
  2. checks the CommonJS cache,
  3. wraps the file in a function,
  4. executes it synchronously if it has not already run,
  5. returns its module.exports value.

Conceptually, Node wraps each CommonJS file like this:

(function (exports, require, module, __filename, __dirname) {
  // Module code
});

This is why require, module, exports, __filename, and __dirname are available inside CommonJS files. They are module-scoped values supplied by Node, not part of the JavaScript language.

Runtime dependency graph

require() is a normal function call, so it can be used conditionally:

if (process.platform === "win32") {
  module.exports = require("./windows.cjs");
} else {
  module.exports = require("./unix.cjs");
}

This is flexible, but the complete dependency graph may not be known until the program executes.

// math.js
export function add(a, b) {
  return a + b;
}
 
// app.js
import { add } from "./math.js";
console.log(add(2, 3));

Static import and export declarations are part of the language syntax. Before evaluating the application, Node can:

  1. parse the modules,
  2. discover their static dependencies,
  3. resolve and link the module graph,
  4. initialize module bindings,
  5. evaluate the graph in dependency order.

This makes the module structure analyzable without executing arbitrary application code. Bundlers can use that structure for optimizations such as tree shaking, although Node itself does not tree-shake application code.

For conditional or lazy loading, both module systems can use asynchronous dynamic import:

const adapter = await import(`./adapters/${name}.js`);

Exported values vs live bindings

CommonJS exports a value through module.exports. require() returns that value—often an object—and callers can hold or destructure it.

// counter.cjs
let count = 0;
 
module.exports = {
  count,
  increment() {
    count += 1;
  },
};

Here, the exported count property was assigned the number 0. Updating the local variable does not automatically replace that property.

ES modules expose live bindings:

// counter.js
export let count = 0;
 
export function increment() {
  count += 1;
}

An importing module observes the current value of count as the exporter updates it. The importer cannot reassign the imported binding.

CommonJS commonly shares object values. ESM links import bindings directly to export bindings.

Execution timing and asynchronous modules

require() is synchronous. The requested CommonJS module finishes executing before require() returns.

ES module loading can represent an asynchronous graph because ESM supports top-level await:

const config = await loadRemoteConfig();
export { config };

Modules depending on this file wait for its top-level promise to settle before their own evaluation continues.

This affects interoperability: CommonJS can use import() to load asynchronous ESM. Modern Node.js can also require() some fully synchronous ES modules, but it cannot synchronously require an ESM graph containing top-level await.

Circular dependencies

The two systems also behave differently when modules depend on each other.

CommonJS cycles

CommonJS executes modules as they are required. During a cycle, Node may return a partially initialized module.exports object from a module that has not finished executing.

The result can depend on statement order.

ES module cycles

ESM links the whole graph first and connects live bindings between modules. This gives it a more structured cycle model, but reading an imported binding before it has been initialized can still throw an error because of the Temporal Dead Zone.

Neither system makes circular dependencies harmless. They often signal that responsibilities should be separated.

How Node decides which module system to use

Be explicit in package.json:

{
  "type": "module"
}
  • .mjs is always treated as an ES module.
  • .cjs is always treated as CommonJS.
  • .js follows the nearest package.json "type" field.
  • "type": "module" makes .js files ESM.
  • "type": "commonjs" makes .js files CommonJS.

Explicit configuration avoids ambiguity for Node, tooling, and package consumers.

Resolution differences

CommonJS resolution historically performs conveniences such as extension searching and directory index lookup:

const util = require("./util"); // May resolve ./util.js or ./util/index.js

ESM uses URL-like resolution. Relative and absolute file imports require explicit extensions:

import util from "./util.js";

Package imports in both systems can be controlled through fields such as "exports" and "imports" in package.json.

Different globals and utilities

CommonJS provides:

  • require
  • module.exports
  • exports
  • __filename
  • __dirname
  • require.resolve()
  • require.main

ES modules instead use:

  • import and export
  • import.meta.filename
  • import.meta.dirname
  • import.meta.url
  • import.meta.resolve()
  • import.meta.main

If an ESM file must use a CommonJS-style loader, Node provides module.createRequire().

Caching and module identity

Both systems normally evaluate a module once and reuse it, which is why module-level state often behaves like a singleton.

However:

  • CommonJS modules are cached in require.cache, primarily by resolved filename.
  • ES modules use a separate loader cache and URL-based identity.
  • The two caches are not the same.
  • Different resolved paths or URLs can produce separate module instances.

This matters when applications rely on module-level state or mix CJS and ESM versions of the same package.

Interoperability

Importing CommonJS from ESM

Node exposes the CommonJS module.exports value as the default export:

import legacyPackage from "legacy-package";

Node may also detect named exports for convenience, but a default import is generally the most reliable model for a CommonJS package.

Loading ESM from CommonJS

Dynamic import() works from CommonJS and returns a promise:

async function main() {
  const { run } = await import("./modern-module.js");
  run();
}

Modern Node versions can use require() for certain synchronous ES modules, but import() remains the safe choice when the dependency may use top-level await or must work across older Node versions.

Package-author concern

Publishing separate CJS and ESM builds can create a dual-package hazard: the same package may be loaded twice with separate state if one consumer loads the CJS entry and another loads the ESM entry.

Package authors should define clear conditional exports and avoid assuming both formats share one in-memory singleton.

Which should new Node.js code use?

Prefer ES modules for new applications and packages when the surrounding ecosystem supports them:

  • they are the JavaScript standard,
  • they work across browsers and runtimes,
  • their dependency structure is statically analyzable,
  • they support top-level await,
  • modern tooling is designed around them.

CommonJS remains reasonable when:

  • maintaining an existing CommonJS codebase,
  • using tooling or dependencies that still expect it,
  • writing a simple synchronous script where migration offers little value.

Avoid mixing the two systems casually. Set an explicit package type and isolate interoperability at clear boundaries.

Interview answer

CommonJS and ES modules differ at the loader and runtime-semantics level, not just syntax. CommonJS was Node’s original module system: require() resolves and executes dependencies synchronously at runtime and returns module.exports. ESM is the ECMAScript standard: static imports are parsed and linked into a dependency graph before evaluation, exports are live bindings, and the graph can be asynchronous through top-level await. They also use different resolution rules, caches, globals, and circular-dependency behavior. In modern Node.js, I prefer ESM for new code, but interoperability and package configuration still need deliberate handling.

Knowledge check — Q&A

Q: Why can require() appear inside an if statement while static import cannot?
require() is a runtime function call. Static import is module syntax that Node analyzes before execution; conditional loading uses dynamic import().

Q: Is __dirname part of JavaScript?
No. Node supplies it to CommonJS modules. ESM uses import.meta.dirname.

Q: Why does ESM help tree shaking?
Its static imports and exports let build tools determine much of the dependency structure without executing the program.

Q: Does Node tree-shake unused ESM exports at runtime?
No. Static structure enables bundlers to tree-shake; Node normally loads and evaluates the module graph.

Q: Can CommonJS load an ES module?
Yes. Dynamic import() is the broadly compatible approach. Modern Node can also require() synchronous ESM graphs that do not use top-level await.

Q: Are CommonJS and ESM module caches shared?
No. Node maintains separate caches for the two loaders.

Q: What is the safest way to identify module format?
Use .mjs or .cjs, or set an explicit "type": "module" or "type": "commonjs" in package.json.

References