Topics
Languages & Runtimes

ES Modules vs CommonJS: How JavaScript Modules Load

Why require runs a file on the spot while import builds the whole graph first, what live bindings and circular imports do, and how Node picks a module system.

Intermediate·14 min read·Updated Oct 7, 2026

JavaScript has two module systems. In CommonJS (CJS), require() is an ordinary function call that finds a file, runs it right away and hands back whatever it put on module.exports. In ES modules (ESM, ECMAScript modules), import is syntax: the runtime parses every file in the graph and wires imports to exports before any of them runs, so imports are live bindings, loading can be asynchronous and bundlers can see exactly what is used. Almost every interop error, circular-import surprise and tree-shaking failure follows from that one difference.

Context

JavaScript shipped in 1995 with no modules at all: every <script> tag shared one global scope, and libraries hid their internals inside immediately invoked functions (the "module pattern"). When JavaScript moved to the server, the CommonJS group (2009) defined require and module.exports, and Node.js adopted it the same year; npm grew up on it. Browsers could not block on synchronous file reads, so AMD (Asynchronous Module Definition) and RequireJS appeared around 2010, and UMD (Universal Module Definition) wrappers tried to work everywhere. The language itself finally got import and export in ES2015; browsers shipped them in 2017-2018, and Node.js enabled ESM without a flag in version 13.2 (November 2019).

You have met both if you have ever seen const fs = require('fs') next to import fs from 'node:fs', a "type": "module" line in package.json, files ending in .mjs and .cjs, or the error "require() of ES Module … not supported". The same tiny module in each system:

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

// consumer
const { add } = require('./math.cjs')
math.mjs
export function add(a, b) { return a + b }

// consumer
import { add } from './math.mjs'
Module scope
Top-level variables in a module are private to it. CJS gets this from a wrapper function; ESM gets it from the language.
Specifier
The string after require or from: a relative path ('./math.mjs'), a package name ('react') or a built-in ('node:fs').
Live binding
An ESM import is a read-only view of the exporting module’s variable, not a copy of its value at import time.
Module graph
All modules reachable from an entry point through imports. ESM builds it completely before running code.
Conditional exports
The package.json "exports" map that tells Node and bundlers which file to load for import, require, types or browser.

Why it matters

Most of the time the bundler hides the difference, until it does not. A dependency goes ESM-only and a CommonJS script crashes with ERR_REQUIRE_ESM; a test runner configured for CJS meets import and reports "Cannot use import statement outside a module"; __dirname is suddenly undefined; a library is published so that two copies of it load and an instanceof check fails. Static ESM imports are also what lets bundlers drop unused code, so the module format you ship directly affects bundle size.

Run as you go vs build the graph first

Node wraps every CommonJS file in a function, (function (exports, require, module, __filename, __dirname) { … }), which is where those five names come from. Calling require('./a') resolves the path, reads the file synchronously, runs the wrapper, caches the result and returns module.exports. Nested requires recurse while the parent is still running, so the dependency graph is only discovered by executing it, and require can sit inside an if or take a computed string.

An ES module goes through three separate phases. Construction fetches and parses every file reachable from the entry point without running any of it. Linking allocates each module's exported variables and points every import at those slots. Evaluation then runs each module exactly once, dependencies first. Because imports must be known at parse time, import declarations are only allowed at the top level with string literals; the runtime-flexible form is the import() expression, which returns a promise.

CommonJS: discover by runningrequire()resolve + readwrap + runmodule.exportsruns the file now, synchronously; nested requires recurseES modules: three phases1 · Construct2 · Link3 · Evaluatefetch + parse allbind importsrun each onceno module code runs until the whole graph is known
CommonJS discovers dependencies by running code, one synchronous require at a time. ESM parses and links the whole graph before any module body runs, which is what makes live bindings, async loading and static analysis possible.

Live bindings vs copied values

require returns an object, and destructuring it copies the values at that moment. An ESM import is a read-only window onto the exporter's variable, so later changes show through.

counter.cjs
let count = 0
module.exports = { count, inc: () => count++ }

// main.cjs
const { count, inc } = require('./counter.cjs')
inc()
console.log(count) // 0  (a copy taken at require time)
counter.mjs
export let count = 0
export const inc = () => count++

// main.mjs
import { count, inc } from './counter.mjs'
inc()
console.log(count) // 1  (live binding)
// count = 5        // TypeError: imports are read-only
CommonJSES modules
Syntaxrequire() function, module.exportsimport / export declarations, import()
When resolvedAt run time, as each require executesAt parse time, before any code runs
LoadingSynchronous file readsCan be asynchronous (network in browsers)
Imports areCopies of exported valuesLive, read-only bindings
Top-level awaitNot availableAllowed (ES2022)
Top-level thismodule.exportsundefined (strict mode always)
Tree-shakingUnreliable: exports are dynamicReliable: exports are static

Circular imports

Two modules that import each other are legal in both systems, and they fail differently. Worked example: a imports b, and b reads a value from a while it is being loaded.

  1. 1
    CommonJS: running a.cjs hits require('./b.cjs') on its first line. Node starts b.cjs, which calls require('./a.cjs').
  2. 2
    Node sees a.cjs is already in the cache, half-finished, and returns its module.exports as it is right now: an empty object, because a has not reached its export line yet.
  3. 3
    b.cjs reads a.name and gets undefined. No error is thrown; the bug surfaces later, somewhere else.
  4. 4
    ESM: linking succeeds because bindings exist before code runs. Evaluation runs b.mjs first (it is the dependency), and when it touches name, the let in a.mjs has not been initialised yet.
  5. 5
    The read throws ReferenceError: Cannot access 'name' before initialization (the temporal dead zone). Loud, and pointing at the line. Function declarations are hoisted, so a cycle that only calls functions later works in both systems.
cycle.mjs
// a.mjs
import { greet } from './b.mjs'
export const name = 'a'
greet()

// b.mjs
import { name } from './a.mjs'
console.log(name)       // ReferenceError: before initialization
export function greet() {
  console.log(name)     // fine: called after a.mjs has run
}

How Node decides, and how packages ship both

Node treats a file as ESM or CJS by its extension first and the nearest package.json second. .mjs is always ESM and .cjs always CommonJS; a plain .js file is ESM only if the nearest package.json has "type": "module". A package's "exports" field (since Node 12.7) then decides which file a specifier maps to, per condition, and hides every file it does not list.

package.json
{
  "name": "tiny-date",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./package.json": "./package.json"
  }
}

Crossing the boundary

ESM can import CommonJS: the default import is module.exports, and Node offers named imports for the exports its static scanner (cjs-module-lexer) can detect. The other direction used to require await import(), because ESM can load asynchronously and require is synchronous. Since Node 22.12 (backported to 20.19), require() can load an ES module without a flag, as long as its graph contains no top-level await. Inside ESM, the CJS-only globals have replacements:

paths.mjs
// Node 20.11+: import.meta.dirname / import.meta.filename
const dir = import.meta.dirname

// older Node versions
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const legacyDir = dirname(fileURLToPath(import.meta.url))

// need require() for a JSON or CJS-only file?
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const config = require('./config.json')

Pitfalls

  • Relying on code in a circular import running first

    In CommonJS a cycle hands back a half-filled exports object, so values read at load time are silently undefined; in ESM the same read throws. Either way the bug comes from top-level code that depends on load order. Break the cycle with a third module or defer the access into a function.

  • Destructuring a CommonJS export and expecting updates

    const { count } = require(...) copies the value once. Code that worked with ESM live bindings breaks when the module is converted to or consumed as CJS. Export a getter function or an object and read its property at the time you need it.

  • Shipping two builds that both hold state

    Separate ESM and CJS builds of a package can both end up in one app, giving two caches, two registries and failing instanceof checks. Make one build the source of truth and have the other re-export it.

  • Leaving out extensions in Node ESM

    Node's ESM loader does not guess: import './utils' fails where './utils.js' works, and directory imports of index.js are not resolved. Bundlers allow both, so code that builds fine can still fail when Node runs it directly.

  • Top-level await in a shared module

    A module with top-level await pauses evaluation of every module that imports it until the promise settles, and makes the module impossible to load with require(). Keep it to entry points and scripts, not library code.

Interview questions

Q1What is the difference between require and import?

require is a synchronous function call that runs the module immediately and returns its module.exports object; import is static syntax resolved before any code runs. That is why ESM imports are live bindings, can load asynchronously, support top-level await and can be tree-shaken, while CommonJS can require conditionally or with a computed path.

Q2What is a live binding?

An ES module import is a read-only reference to the exporting module's variable, not a copy. If the exporter changes the variable, importers see the new value, and assigning to the import throws. Destructuring a CommonJS require instead copies the value at the moment of the call.

Q3What happens when two ES modules import each other?

Linking works, because bindings are created before any code runs. During evaluation the dependency runs first, and if its top-level code reads a let or const from the other module before that module has run, it throws a ReferenceError from the temporal dead zone. In CommonJS the same cycle returns a partially filled exports object and the value is silently undefined.

Q4Walk me through publishing a package that works with both import and require.

Since Node 22.12 can require ESM, the simplest answer is to ship ESM only with an exports map and no top-level await. If older Node versions must be supported, I build ESM and CJS outputs, map them with import and require conditions plus a types condition first, and avoid the dual-package hazard by keeping any module-level state in one build that the other re-exports. Then I test both entry points from a real ESM and a real CJS consumer.

Q5Why can bundlers tree-shake ES modules but not CommonJS reliably?

ESM imports and exports are static declarations, so a bundler can read the whole graph and prove which exports nobody imports. CommonJS exports are a mutable object built at run time, possibly with computed keys or conditional assignments, so the bundler cannot know what is used without running the code and usually keeps everything.

Q6You get ERR_REQUIRE_ESM. What is happening and how do you fix it?

A CommonJS file called require() on a package that only ships ESM, on a Node version that cannot require ESM. Options: upgrade to Node 22.12 or 20.19+ if the package has no top-level await, switch the call to await import(), convert the caller to ESM, or pin the last version of the dependency that shipped CommonJS.

Q7How does Node decide whether a .js file is CommonJS or an ES module?

By the nearest package.json: "type": "module" makes .js files ESM, and its absence means CommonJS. The extensions .mjs and .cjs override that per file. For packages, the exports map then chooses which file each import or require actually loads.

Key takeaways
  • require runs a file immediately and returns an object; import is resolved for the whole graph before any module runs.
  • ESM imports are live, read-only bindings; destructured CommonJS imports are copies taken at require time.
  • Circular imports give silently undefined values in CommonJS and a temporal dead zone error in ESM. Fix them by moving the shared piece out.
  • Node picks the system by extension, then "type" in package.json; the exports map picks the file per condition.
  • Dual builds risk two copies of module state. Since Node 22.12, require can load ESM, which makes ESM-only packages practical.
  • Static imports are what make tree-shaking reliable, so ship ESM for code that ends up in bundles.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free