JavaScript Part 2
Modules: ES Modules, CommonJS, Bundlers and Tree Shaking
Live bindings versus copies, Node's module rules, circular imports, and a real bundle built with esbuild.
On this page
- Words used in this lesson
- Part 1. Why modules
- Part 2. CommonJS
- Part 3. ES modules
- Part 4. Dynamic import() and top-level await
- Part 5. How Node picks CommonJS or ESM
- Part 6. Interop: when the two systems meet
- Part 7. Circular imports
- Part 8. What a bundler does
- Try it
- Interview questions
- Key takeaways
- What's next
In one line: a module is a file with its own private scope that chooses what to share. CommonJS (require) loads files at run time and shares copies of values. ES modules (import) are analysed before running and share live links. Bundlers use that fixed structure to join files and to drop code nobody imports (tree shaking).
Early web pages had one global scope, so every script could overwrite the others' variables. Modules fix that. This lesson shows both module systems by running them, the rules Node uses to pick one, the pitfalls when they meet, and what a bundler like esbuild does with your imports. It builds on scope and the scope chain, and on Lesson 2 for await. For interview practice see JavaScript Core Interview Questions (it has a modules section).
What I ran / checked. Everything ran on Node v22.22.0, Linux. Bundling used esbuild 0.25.10 installed from npm (I pinned that version). I read these sections from the nodejs/node repository (main branch): doc/api/packages.md (syntax detection) and doc/api/modules.md (loading ES modules with require()), and I ran each rule on this machine. Behaviour differs between Node versions: this lesson describes 22.22 only. I did not run webpack, Vite, Rollup or a browser; what I say about them is from general knowledge and is marked as such.
Words used in this lesson
- Module is a file (or unit) with its own scope. Names inside are private unless exported.
- CommonJS (CJS) is Node's original module system:
require()andmodule.exports. - ES modules (ESM) are the language standard:
importandexport. - Binding is the link between a name and its variable. A live binding follows the variable; a copy does not.
- Module graph is the tree of files that import each other, starting at an entry file.
- Bundler is a tool that reads the graph and writes one or a few output files for the browser.
- Tree shaking is removing exports that nothing imports.
- Side effect is something a file does just by running (print, change a global, register something), not only by exporting values.
- Specifier is the string in
import ... from "specifier".
Part 1. Why modules
Without modules, a page loads scripts one after another. Everything lands in the same global scope:
<script src="a.js"></script> <!-- var total = 1 --><script src="b.js"></script> <!-- var total = 2 (overwrites a.js) -->Problems: name clashes, order that you must manage by hand, no way to say "this file needs that file", and no way to hide helpers. A module gives each file its own scope (the idea from closures and the scope chain, but per file), a list of what it exports, and a list of what it imports. Then tools can see dependencies without running the code.
Part 2. CommonJS
Node's first system. Two files:
// math.jsconsole.log("math.js is running; module.id =", module.id === "." ? "." : "(not main)");let count = 0;function inc() { count++; return count; }module.exports = { count, inc };// main.jsconst m = require("./math");const m2 = require("./math");console.log(m === m2, m.count, m.inc(), m.count);console.log(typeof require, typeof module, typeof exports, exports === module.exports);console.log(require.cache[require.resolve("./math")].loaded, Object.keys(require.cache).length);console.log(__filename.endsWith("main.js"), typeof __dirname);console.log(arguments.length, Object.keys(arguments).map(k => typeof arguments[k]).join(","));exports.a = 1; module.exports = { b: 2 }; exports.c = 3;console.log(module.exports);Output of node main.js:
math.js is running; module.id = (not main)true 0 1 0function object object truetrue 2true string5 object,function,object,string,string{ b: 2 }What this shows:
- A file runs once.
math.jsprinted once although it was required twice.requirecaches by file path, som === m2. m.countstayed0afterm.inc()returned1.module.exports = { count, inc }copied the number0into the object at that moment. The variablecountinside the file went up, but the exported property did not. CommonJS shares values, not variables.require,module,exports,__filename,__dirnameare not globals. Node wraps each file in a function with exactly these five parameters, which is whyarguments.lengthis5. That is the module's private scope.exportsandmodule.exportsstart as the same object. Assigning a new object tomodule.exportscuts the link.exports.a = 1was lost, andexports.c = 3also went to the old object. Onlymodule.exportsis whatrequirereturns ({ b: 2 }).
require in about fifteen lines
const fs = require("fs"), path = require("path"), vm = require("vm");const cache = {};function myRequire(file) { const full = path.resolve(file); if (cache[full]) return cache[full].exports; const module = { exports: {} }; cache[full] = module; const src = fs.readFileSync(full, "utf8"); const wrapper = vm.runInThisContext("(function (exports, require, module, __filename, __dirname) {" + src + "\n})"); wrapper(module.exports, (f) => myRequire(path.resolve(path.dirname(full), f.endsWith(".js") ? f : f + ".js")), module, full, path.dirname(full)); return module.exports;}const m = myRequire(path.join(__dirname, "math.js"));const m2 = myRequire(path.join(__dirname, "math.js"));console.log(m === m2, m.inc());Output:
math.js is running; module.id = (not main)true 1The steps: find the file, return the cached exports if present, otherwise register the module in the cache before running it, wrap the source in a function, run it, and return module.exports. Real Node adds path search (node_modules, index.js, extensions), JSON and other loaders. Notice that require is synchronous: it reads the file and blocks until the code has run. That is fine on a server with a disk, and the reason this design does not suit a browser, where files come over a network.
Part 3. ES modules
// counter.mjsexport let count = 0;export function inc() { count++; }export default function hello() { return "default hello"; }export const PI = 3.14;// main.mjsimport hello, { count, inc, PI } from "./counter.mjs";import * as ns from "./counter.mjs";console.log(count); inc(); console.log("after inc:", count, ns.count);try { count = 5; } catch (e) { console.log(e.constructor.name + ": " + e.message); }console.log(Object.keys(ns), ns.default === hello, ns[Symbol.toStringTag]);try { ns.PI = 3; } catch (e) { console.log(e.constructor.name + ": " + e.message); }console.log(typeof require, typeof module, this, typeof import.meta.url, import.meta.url.startsWith("file://"), typeof import.meta.dirname);Output of node main.mjs:
0after inc: 1 1TypeError: Assignment to constant variable.[ 'PI', 'count', 'default', 'inc' ] true ModuleTypeError: Cannot assign to read only property 'PI' of object '[object Module]'undefined undefined undefined string true string- Live binding.
countchanged from0to1in the importer afterinc(). The imported name is a read-only window onto the exporter's variable, not a copy. This is the main difference from the CommonJS example above (m.countstayed0). - Importers cannot assign to an import (
count = 5threw), even thoughcountis declared withletin the exporting file. Only the owner can change it. import * as nsgives a module namespace object: frozen shape, keys sorted,Symbol.toStringTagis"Module".- Inside an ES module there is no
require,moduleor__dirname; top-levelthisisundefined;import.meta.url(and in Node 22,import.meta.dirname) take their place. ES modules are always in strict mode.
Destructuring a CommonJS export (const { count } = require(...)) has the same copy effect: I ran it and the local count stayed 0 while a fresh read of require(...).count gave 1.
Static structure
import and export are allowed only at the top level, and the specifier must be a string literal. So before running anything, the engine (or a bundler) can read the file, collect the names, and find the whole graph. ESM loading has phases: find and parse every file, link (connect the import names to the export variables, creating the live bindings), then evaluate the code in dependency order. This static shape is what makes tree shaking possible (Part 8).
Default and named exports
| Style | Export | Import | Notes |
|---|---|---|---|
| Named | export const a = 1 | import { a } from "./m.js" | name must match; can rename with as |
| Default | export default fn | import anything from "./m.js" | the importer picks the name, which makes renames easy to get out of sync |
| Namespace | import * as m from "./m.js" | all exports as one object | |
| Re-export | export { a } from "./m.js" | what "barrel" files (index.js) do |
Many teams prefer named exports: tools can rename them safely and an import of a misspelt name fails at link time. A default export is only a named export called default; the output above shows ns.default === hello.
Part 4. Dynamic import() and top-level await
import("./x.js") is an expression. It takes any string, runs at run time, and returns a promise of the namespace object (see Lesson 2). It also works in CommonJS files.
// main.mjsconsole.log("main start");const name = "./heavy.mjs";const mod = await import(name); // top-level await + dynamic specifierconsole.log(mod.answer, mod.default, Object.keys(mod));const again = await import(name);console.log(mod === again);try { await import("./nope.mjs"); } catch (e) { console.log(e.code); }const cjs = await import("node:path");console.log(typeof cjs.join, typeof cjs.default.join);console.log("main end");Output (with heavy.mjs printing "heavy.mjs evaluated" when it runs):
main startheavy.mjs evaluated42 default-value [ 'answer', 'default' ]trueERR_MODULE_NOT_FOUNDfunction functionmain end- The module code ran only when
import()was reached, and only once (mod === again). - A missing file rejects the promise, so you can catch it.
- In browsers, bundlers use
import()as a split point: the code behind it goes to a separate file that is downloaded on demand (lazy loading, e.g. a route or a heavy chart library).
Top-level await lets a module use await outside a function. Modules that import it wait for it. Only the dependency chain waits, not unrelated modules:
// slow.mjs: console.log("slow: start"); await new Promise(r => setTimeout(r, 30)); export const v = "slow value"; console.log("slow: done");// fast.mjs: console.log("fast: evaluated"); export const f = 1;// tla.mjsimport { v } from "./slow.mjs";import { f } from "./fast.mjs";console.log("tla main:", v, f);slow: startfast: evaluatedslow: donetla main: slow value 1fast.mjs did not wait for slow.mjs. But tla.mjs waited for both. A CommonJS file that does require("./slow.mjs") gets the error ERR_REQUIRE_ASYNC_MODULE, because require is synchronous and cannot wait (I ran it). Use top-level await sparingly: it slows startup for everyone downstream.
Part 5. How Node picks CommonJS or ESM
Node decides per file:
| File | Treated as |
|---|---|
.mjs | ES module, always |
.cjs | CommonJS, always |
.js, nearest package.json has "type": "module" | ES module |
.js, nearest package.json has "type": "commonjs" | CommonJS |
.js, no package.json or no type field | ambiguous: Node looks at the syntax |
The last row is "syntax detection". The Node documentation says it has been enabled by default since v22.7.0 (and v20.19.0): if the file contains ES module syntax (import/export statements, import.meta, or top-level await), it is run as ESM. I ran these on 22.22:
$ cat a.jsimport fs from "node:fs"; console.log("detected as ESM");$ node a.jsdetected as ESM$ node --no-experimental-detect-module a.jsSyntaxError: Cannot use import statement outside a moduleWith {"type": "commonjs"} in package.json the same file also failed with SyntaxError: Cannot use import statement outside a module, because an explicit type turns detection off. And in a folder with {"type":"module"}, a .js file using require failed with:
ReferenceError: require is not defined in ES module scope, you can use import insteadThis file is being treated as an ES module because it has a '.js' file extension and '.../package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.Renaming it to .cjs worked. My advice: set "type" explicitly in every package.json and do not depend on detection. Detection needs Node to try parsing, and older Node versions do not have it.
ESM in Node also has stricter path rules. A relative import needs the full file extension. I ran import { helper } from "./helper" from an .mjs file where helper.js existed, and got Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../helper' imported from .... CommonJS adds .js and tries index.js for you; ESM does not.
Part 6. Interop: when the two systems meet
| From | To | How | Result on Node 22.22 |
|---|---|---|---|
| ESM | CJS | import x from "./lib.cjs" | works. The default import is module.exports. Named imports work only when Node can detect the names by scanning the source |
| CJS | ESM | await import("./x.mjs") | works anywhere (async) |
| CJS | ESM | require("./x.mjs") | works if the ES module has no top-level await; otherwise ERR_REQUIRE_ASYNC_MODULE |
The require(esm) row is new: the Node documentation shows it is no longer behind a flag since v22.12.0. Older Node versions throw ERR_REQUIRE_ESM. I ran all three:
// lib.cjs: module.exports = function greet() { return "hi"; }; module.exports.extra = 1;// named.cjs: exports.alpha = 1; exports.beta = 2;import greet from "./lib.cjs";import * as ns from "./lib.cjs";import { alpha } from "./named.cjs";import named from "./named.cjs";console.log(greet(), greet.extra, Object.keys(ns), alpha, named);hi 1 [ 'default', 'extra' ] 1 { alpha: 1, beta: 2 }The default import is the whole module.exports function; { alpha } works because Node scanned named.cjs and found the exports.alpha = assignments. And the other direction:
// esm-only.mjs: export const x = 1; export default "dflt";const m = require("./esm-only.mjs");console.log(m, m.default, m.__esModule);import("./esm-only.mjs").then((n) => console.log("dynamic:", n.default));[Module: null prototype] { __esModule: true, default: 'dflt', x: 1 } dflt truedynamic: dfltrequire of an ES module returns the namespace object, so the default export is under .default. Node adds __esModule: true for tools that expect it.
Pitfalls to remember:
- Default import confusion.
typeof require("./lib.cjs").defaultwasundefinedfor a plain CommonJS function (I ran it). A TypeScript or Babel build may add__esModuleandexports.default, so you sometimes seelib.default.default. When something isundefined, print the object and look. - CJS named exports are guesses. Detection works for simple
exports.x =forms. It can miss names built dynamically (Object.assign(module.exports, ...)), and thenimport { x }fails with aSyntaxErrorat link time. Use the default import and read the property instead. __dirnameandrequireare gone in ESM. Useimport.meta.dirname(Node 22) orcreateRequire(import.meta.url). I rancreateRequireand a JSON import written asimport data from "./data.json" with { type: "json" }: both worked on 22.22 and printed{ k: 1 }for the JSON. JSON imports need thewith { type: "json" }attribute.
Part 7. Circular imports
Two files can import each other. CommonJS: you get what the other file has exported so far.
// a.cjsconsole.log("a starts"); exports.early = "a-early";const b = require("./b.cjs");console.log("in a, b.done =", b.done); exports.done = true; console.log("a ends");// b.cjsconsole.log("b starts");const a = require("./a.cjs");console.log("in b, a.early =", a.early, "| a.done =", a.done);exports.done = true; console.log("b ends");node a.cjs printed:
a startsb startsin b, a.early = a-early | a.done = undefinedb endsin a, b.done = truea ends(node:23780) Warning: Accessing non-existent property 'done' of module exports inside circular dependencyWhen b required a, a was only half finished: early was there, done was not. The require.cache entry was registered before running (as in my myRequire), which prevents an endless loop, and hands out a partial object. Node even warns about the missing property.
ESM: bindings exist from the link phase, but const variables are in their temporal dead zone (temporal dead zone) until the exporting file runs:
// a.mjsimport { b } from "./b.mjs";console.log("a runs, b =", b);export const a = "A";// b.mjsimport { a } from "./a.mjs";console.log("b runs");try { console.log(a); } catch (e) { console.log(e.constructor.name + ": " + e.message); }export const b = "B";setTimeout(() => console.log("later, a =", a), 0);node a.mjs:
b runsReferenceError: Cannot access 'a' before initializationa runs, b = Blater, a = AThe order: a starts, sees it imports b, so b is evaluated first (it is deeper). b reads a too early: a ReferenceError. Later (in the timer), the live binding is filled in, and a is "A". Function declarations are hoisted, so a cycle where one file only calls the other's functions after both finished works. I ran f1.mjs/f2.mjs with functions only and it printed f1 calls g: g+f.
How to avoid cycles: move the shared part into a third file that both import, or pass values in as arguments. Linters can find cycles (I did not run any).
Part 8. What a bundler does
Browsers can load ES modules directly, but a real app has hundreds of files and node_modules. Loading each one costs a network request, and old browsers lack support. A bundler (esbuild, webpack, Rollup, Vite, Turbopack) reads your entry file, follows every import to build the graph, can transform code (TypeScript, JSX), and writes a few output files. It also does these jobs: resolve node_modules, minify, split code at import(), and drop unused exports.
Here is a tiny project (three files) and its bundle with esbuild 0.25.10:
// src/math.jsexport function add(a, b) { return a + b; }export function multiply(a, b) { return a * b; }export function unusedBig() { return Array.from({ length: 5 }, (_, i) => i * 2).join("-"); }export const unusedConst = "never imported";// src/greet.jsimport { add } from "./math.js";export default function greet(name) { return "Hello " + name + " " + add(1, 2); }// src/main.jsimport greet from "./greet.js";import { multiply } from "./math.js";console.log(greet("bundle"), multiply(3, 4));$ npx esbuild src/main.js --bundle --outfile=out/dev.js(() => { // src/math.js function add(a, b) { return a + b; } function multiply(a, b) { return a * b; } // src/greet.js function greet(name) { return "Hello " + name + " " + add(1, 2); } // src/main.js console.log(greet("bundle"), multiply(3, 4));})();Look at what happened:
- Files were joined in dependency order, with the module scopes flattened into one function wrapper (an IIFE, so nothing leaks to the global scope).
unusedBigandunusedConstare gone. That is tree shaking: they are exported but nobody imports them.- The import statements are gone: the names are plain variables now. Where a name could clash, a bundler renames it.
With --minify the output was one line of 140 bytes (node out/min.js printed Hello bundle 3 12), against 287 bytes unminified. With --tree-shaking=false, unusedBig stayed in the file (I counted the name with grep -c: 1 against 0). Those sizes are only for this tiny example.
Why ESM can be shaken, and when it cannot
Tree shaking works because import/export are static: the tool knows which names are used without running anything. The harder question is side effects. Dropping a file that prints or changes a global would change the program. So a bundler removes a whole file only if it is sure the file has no side effects. A package can promise that with "sideEffects": false in its package.json. I made a fake package fakelib whose index.js re-exports used and heavy, where heavy.js has a top-level console.log. I imported only used:
// "sideEffects" field missing // "sideEffects": false(() => { (() => { var used = "USED"; var used = "USED"; console.log("heavy.js side effect!"); console.log(used); var heavy = "HEAVY ".repeat(3); })(); console.log(used);})();(I put two real esbuild outputs side by side, with the file-name comments removed.) Without the field, esbuild had to keep heavy.js and its console.log, because it might matter. With "sideEffects": false, the whole file disappeared. The field is a promise from the library author, so a wrong false breaks things such as CSS imports or polyfills.
Other rules (what I ran, and what I know):
- CommonJS is hard to shake. I bundled a
cjslibwithexports.usedandexports.unusedBig. esbuild kept both and wrapped the package in a__commonJShelper, plus about 30 lines of interop helpers (I left those out). A module system where exports are assigned at run time cannot be analysed with confidence. Prefer ESM builds of libraries. - Pure annotations. In another test,
const a = /* @__PURE__ */ makeThing();was removed becauseawas unused, andconst b = makeThing();was kept: the bundler cannot knowmakeThing()has no side effects unless you say so. - Barrel files (
index.jsthat re-exports everything) are fine for shaking when the package declaressideEffects: false. Without it, importing one name can pull in many files. - Code splitting. Running
esbuild src/split.js --bundle --splitting --format=esm --outdir=out/spliton a file withimport("./math.js")produced two files:split.jsandmath-JE4PH7MQ.js. The second is only fetched whenimport()runs.
Different bundlers have different defaults and configuration names (webpack uses optimization.sideEffects and usedExports, Rollup and Vite have their own). I only ran esbuild. Check your tool's own docs before you rely on tree shaking, and look at the real output (or a bundle analyser) rather than assuming.
Try it
- A file does
module.exports = { n }wherelet n = 1, and latern++. What doesrequiresee? What would an ESMexport let nshow? - Why does
exports = { a: 1 }export nothing in CommonJS? - You get
ReferenceError: require is not defined in ES module scope. Give two fixes. - A circular import gives
undefinedfor a function you imported with CommonJS. Why, and what is one fix? - You import one function from a library and your bundle still contains the whole library. Name two likely causes.
Answers:
requiresees the old value1: the number was copied when the object was built (mymath.jsexample:0stayed0). The ESMexport let nis a live binding and the importer sees2(mycountexample).exportsis only a variable that starts pointing atmodule.exports. Assigning a new object changes the variable, not the object thatrequirereturns. Writemodule.exports = ...(I ran this: only{ b: 2 }was exported).- Rename the file to
.cjs, or rewrite it withimport. Or, inside an ES module, usecreateRequire(import.meta.url)to get arequirefunction. Both file-rename andcreateRequireare shown above. - When
brequireda,ahad not finished andmodule.exportsheld only the names exported so far. Fix: move the shared code into a third module, or callrequireinside the function that needs it (later, whenais done). I ran the first part (the partiala). - (a) The library is CommonJS, so it cannot be shaken. (b) The library has no
"sideEffects": false, so files with unknown side effects are kept. Another cause: you import through a barrel file in your own code that has side effects. I ran causes (a) and (b) with esbuild.
Interview questions
What is the difference between CommonJS and ES modules? (Must know)
CommonJS loads at run time with require, which is synchronous and returns a copy of module.exports. ES modules are static: import and export are read before the code runs, then linked, then evaluated, and imports are live bindings. ESM supports top-level await, is always strict, and can be tree shaken. In CommonJS I can require inside an if; in ESM I use import().
What is a live binding? Show it. (Must know)
An imported name points at the exporter's variable, not at a copy of its value. If the exporter does export let count = 0 and then runs count++, importers see 1. Importers cannot assign to it. In CommonJS, module.exports = { count } copies the number, so the importer keeps 0 (I ran both).
What is the difference between exports and module.exports? (Trap)
At the start, exports is a variable pointing to the same object as module.exports. require returns module.exports. So exports.a = 1 works, but exports = {...} does nothing, and assigning to module.exports replaces everything (earlier exports.x additions are lost).
Default export or named exports? (Senior signal)
I prefer named exports. The name is fixed, so tools can rename it, editors can auto-import it and a typo fails at link time. A default export lets each importer pick a different name, which makes search and refactoring harder. I use a default export where a framework requires one (for example, a page component).
How does Node decide if a .js file is CommonJS or ESM? (Must know)
By extension first: .mjs is ESM, .cjs is CommonJS. For .js, the nearest package.json "type" decides. If there is no type, Node 22.7 and later look at the syntax and use ESM if it finds import, export, import.meta or top-level await. I set "type" explicitly so the result does not depend on detection.
Can CommonJS load an ES module? (Senior signal)
Yes. await import() always works. Since Node 22.12 require() also works for an ES module if it has no top-level await; if it has one, Node throws ERR_REQUIRE_ASYNC_MODULE. I confirmed both on Node 22.22. On older Node versions it throws ERR_REQUIRE_ESM, so a library that must support them should offer import().
What happens in a circular import? (Trap)
In CommonJS the second require returns the partly filled module.exports of the file that is still running, so some names are undefined. In ESM the names are bound at link time, but a const or let export is in the temporal dead zone until its file runs, giving a ReferenceError if used too early. Function declarations work. The real fix is to remove the cycle by moving shared code to a third module.
What is tree shaking and what blocks it? (Must know)
Tree shaking is a bundler step that removes exports that nothing imports. It relies on the static shape of ESM. It is blocked by CommonJS code, by modules with top-level side effects that are not marked sideEffects: false, and by code the bundler cannot prove pure. I check the output (for example by searching for a function name) instead of trusting it.
What is import() used for? (Must know)
It loads a module on demand and returns a promise of its namespace. I use it for code splitting (load a heavy chart library only on the page that needs it), for loading a module chosen at run time, and to load an ES module from CommonJS. A bundler turns each import() into a separate file.
Key takeaways
- A module is a file with its own scope. CommonJS shares copies at run time; ESM shares live bindings, linked before running.
requireis synchronous and cached.exportsis only a shortcut tomodule.exports.- In Node,
.mjsis ESM,.cjsis CommonJS, and.jsfollowspackage.json"type". Set it explicitly. ESM needs full file extensions. import()is async and works in both. Top-levelawaitmakes a module async, andrequire()of such a module fails.- Circular imports give partial exports (CJS) or temporal dead zone errors (ESM). Remove the cycle.
- Bundlers join the graph and drop unused exports. Tree shaking needs ESM and safe side effects (
"sideEffects": false). Look at the real output.
What's next
Next: Iterators, Generators and Symbols, Lesson 4. Practise: make a tiny project with two files in a cycle, run it as .cjs and as .mjs, and write down which names are undefined or throw at each step.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- What Node.js Is and How It Runs Your Code · Node.js From Zero
- Modules, Declaration Files and tsconfig in Real Projects · TypeScript From Zero
Part of the study guide
Related series