Skip to content
C Codeloom
JavaScript

JavaScript Modules: ESM vs CommonJS

A practical comparison of ES Modules and CommonJS: how they load, how they differ, interop strategies, and how to choose for a new project.

·5 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How ESM and CommonJS load modules
  • The static vs dynamic loading difference
  • Interop rules between the two
  • Dual-package hazards
  • How to choose for a new project

Prerequisites

  • Comfortable with JavaScript files and imports

For years, Node was CommonJS and the browser was script tags. Then bundlers brought ES Modules (ESM) to the browser, Node adopted ESM, and now most projects live somewhere in between. The two systems look similar on the surface but differ in important ways. Knowing those differences keeps you out of the dual-package swamp.

What and why

CommonJS (CJS) is Node’s original module system. You use require() to load and module.exports to expose. Loading is synchronous and dynamic: require runs at the moment it executes, and the path can be computed at runtime.

ES Modules (ESM) are the standard JavaScript module system, defined in the ECMAScript spec. You use import and export. Imports are static: their names and paths are resolved before any code runs, which enables tree-shaking and circular-dependency analysis at build time.

Both exist because they evolved independently. The browser needed something safe for the network (declarative, async, fetchable). Node needed something quick for the file system (synchronous, dynamic). ESM eventually won as the standard.

Mental model

CJS is a function call: when control reaches require('x'), Node loads x, runs it top to bottom, and hands you the result. ESM is a graph: the engine reads import statements, builds a dependency graph, fetches and parses all modules, then executes them in order.

CommonJS (sync, dynamic):
run code -> hit require('x') -> load x synchronously -> continue

ESM (static graph):
parse all imports -> build dependency graph
 |
 v
fetch & parse every module in graph
 |
 v
execute in topological order
Module loading differences

This is why you can wrap require in a conditional but you cannot wrap a top-level import statement. ESM has a dynamic import() for runtime loading, but it returns a promise.

Hands-on example

CommonJS:

// math.cjs
function add(a, b) { return a + b; }
module.exports = { add };

// app.cjs
const { add } = require('./math.cjs');
console.log(add(2, 3));

ESM:

// math.mjs
export function add(a, b) { return a + b; }

// app.mjs
import { add } from './math.mjs';
console.log(add(2, 3));

In Node, the file extension or the nearest package.json "type" field decides which system applies. .mjs is always ESM. .cjs is always CJS. .js follows package.json: "type": "module" means ESM, otherwise CJS.

Interop

You can import a CJS module from ESM:

import lodash from 'lodash'; // default import gets module.exports
import { merge } from 'lodash'; // named imports work for many CJS modules via static analysis

You cannot use synchronous require to load ESM. The escape hatch is dynamic import:

const mod = await import('./esm-only.mjs');
mod.something();

Bundlers (Webpack, Vite, esbuild) paper over a lot of this by transforming both formats into their own internal module representation. In raw Node, the rules are stricter.

Dual-package hazards

Publishing a library that supports both ESM and CJS introduces “dual instance” risk: if a user imports the ESM build and another dependency requires the CJS build, you end up with two copies of the module in memory. Classes, singletons, and instanceof checks break across the boundary.

{
  "name": "my-lib",
  "exports": {
    ".": {
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

To minimize hazards, keep stateful singletons out of dual-exposed packages, or ship ESM only and tell users to upgrade.

Common pitfalls

  • Top-level await in CJS. Not allowed. Works only in ESM.
  • __dirname and __filename in ESM. Not defined. Use import.meta.url and fileURLToPath instead.
  • Forgetting file extensions in ESM. Node ESM requires the explicit .js or .mjs. Bundlers often hide this; Node does not.
  • Mixing module.exports and export in the same file. They are different worlds.

Best practices

  • For new Node projects, default to ESM. Set "type": "module" and write import/export.
  • For libraries with broad reach, ship dual builds via the exports field, with explicit import and require conditions.
  • Use dynamic import() when you need conditional or lazy loading.
  • Avoid putting mutable state in module top-level scope of dual-published packages.

FAQ

Is ESM slower than CJS in Node? Historically yes, but modern Node has closed most of the gap. Differences are usually irrelevant outside microbenchmarks.

Can I use import in a .js file without "type": "module"? No. Node will throw a syntax error. Either change the extension to .mjs or set the type.

Do bundlers prefer ESM? Yes. Tree-shaking depends on static import statements, so ESM enables smaller bundles in practice.

Is CJS deprecated? Not formally, but the ecosystem is moving toward ESM-first. Many new packages publish ESM only.