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.

Lesson 0330 min read#modules#esm#bundlers

On this page

  1. Words used in this lesson
  2. Part 1. Why modules
  3. Part 2. CommonJS
  4. Part 3. ES modules
  5. Part 4. Dynamic import() and top-level await
  6. Part 5. How Node picks CommonJS or ESM
  7. Part 6. Interop: when the two systems meet
  8. Part 7. Circular imports
  9. Part 8. What a bundler does
  10. Try it
  11. Interview questions
  12. Key takeaways
  13. 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() and module.exports.
  • ES modules (ESM) are the language standard: import and export.
  • 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:

HTML
<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:

JavaScript
// 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 };
JavaScript
// 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:

Text
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:

  1. A file runs once. math.js printed once although it was required twice. require caches by file path, so m === m2.
  2. m.count stayed 0 after m.inc() returned 1. module.exports = { count, inc } copied the number 0 into the object at that moment. The variable count inside the file went up, but the exported property did not. CommonJS shares values, not variables.
  3. require, module, exports, __filename, __dirname are not globals. Node wraps each file in a function with exactly these five parameters, which is why arguments.length is 5. That is the module's private scope.
  4. exports and module.exports start as the same object. Assigning a new object to module.exports cuts the link. exports.a = 1 was lost, and exports.c = 3 also went to the old object. Only module.exports is what require returns ({ b: 2 }).

require in about fifteen lines

JavaScript
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:

Text
math.js is running; module.id = (not main)true 1

The 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

JavaScript
// counter.mjsexport let count = 0;export function inc() { count++; }export default function hello() { return "default hello"; }export const PI = 3.14;
JavaScript
// 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:

Text
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. count changed from 0 to 1 in the importer after inc(). 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.count stayed 0).
  • Importers cannot assign to an import (count = 5 threw), even though count is declared with let in the exporting file. Only the owner can change it.
  • import * as ns gives a module namespace object: frozen shape, keys sorted, Symbol.toStringTag is "Module".
  • Inside an ES module there is no require, module or __dirname; top-level this is undefined; 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

StyleExportImportNotes
Namedexport const a = 1import { a } from "./m.js"name must match; can rename with as
Defaultexport default fnimport anything from "./m.js"the importer picks the name, which makes renames easy to get out of sync
Namespaceimport * as m from "./m.js"all exports as one object
Re-exportexport { 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.

JavaScript
// 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):

Text
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:

JavaScript
// 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);
Text
slow: startfast: evaluatedslow: donetla main: slow value 1

fast.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:

FileTreated as
.mjsES module, always
.cjsCommonJS, 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 fieldambiguous: 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:

Text
$ 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 module

With {"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:

Text
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

FromToHowResult on Node 22.22
ESMCJSimport 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
CJSESMawait import("./x.mjs")works anywhere (async)
CJSESMrequire("./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:

JavaScript
// 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);
Text
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:

JavaScript
// 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));
Text
[Module: null prototype] { __esModule: true, default: 'dflt', x: 1 } dflt truedynamic: dflt

require 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:

  1. Default import confusion. typeof require("./lib.cjs").default was undefined for a plain CommonJS function (I ran it). A TypeScript or Babel build may add __esModule and exports.default, so you sometimes see lib.default.default. When something is undefined, print the object and look.
  2. CJS named exports are guesses. Detection works for simple exports.x = forms. It can miss names built dynamically (Object.assign(module.exports, ...)), and then import { x } fails with a SyntaxError at link time. Use the default import and read the property instead.
  3. __dirname and require are gone in ESM. Use import.meta.dirname (Node 22) or createRequire(import.meta.url). I ran createRequire and a JSON import written as import data from "./data.json" with { type: "json" }: both worked on 22.22 and printed { k: 1 } for the JSON. JSON imports need the with { type: "json" } attribute.

Part 7. Circular imports

Two files can import each other. CommonJS: you get what the other file has exported so far.

JavaScript
// 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:

Text
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 dependency

When 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:

JavaScript
// 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:

Text
b runsReferenceError: Cannot access 'a' before initializationa runs, b = Blater, a = A

The 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:

JavaScript
// 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));
Text
$ npx esbuild src/main.js --bundle --outfile=out/dev.js
JavaScript
(() => {  // 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:

  1. Files were joined in dependency order, with the module scopes flattened into one function wrapper (an IIFE, so nothing leaks to the global scope).
  2. unusedBig and unusedConst are gone. That is tree shaking: they are exported but nobody imports them.
  3. 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:

Text
// "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 cjslib with exports.used and exports.unusedBig. esbuild kept both and wrapped the package in a __commonJS helper, 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 because a was unused, and const b = makeThing(); was kept: the bundler cannot know makeThing() has no side effects unless you say so.
  • Barrel files (index.js that re-exports everything) are fine for shaking when the package declares sideEffects: false. Without it, importing one name can pull in many files.
  • Code splitting. Running esbuild src/split.js --bundle --splitting --format=esm --outdir=out/split on a file with import("./math.js") produced two files: split.js and math-JE4PH7MQ.js. The second is only fetched when import() 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

  1. A file does module.exports = { n } where let n = 1, and later n++. What does require see? What would an ESM export let n show?
  2. Why does exports = { a: 1 } export nothing in CommonJS?
  3. You get ReferenceError: require is not defined in ES module scope. Give two fixes.
  4. A circular import gives undefined for a function you imported with CommonJS. Why, and what is one fix?
  5. You import one function from a library and your bundle still contains the whole library. Name two likely causes.

Answers:

  1. require sees the old value 1: the number was copied when the object was built (my math.js example: 0 stayed 0). The ESM export let n is a live binding and the importer sees 2 (my count example).
  2. exports is only a variable that starts pointing at module.exports. Assigning a new object changes the variable, not the object that require returns. Write module.exports = ... (I ran this: only { b: 2 } was exported).
  3. Rename the file to .cjs, or rewrite it with import. Or, inside an ES module, use createRequire(import.meta.url) to get a require function. Both file-rename and createRequire are shown above.
  4. When b required a, a had not finished and module.exports held only the names exported so far. Fix: move the shared code into a third module, or call require inside the function that needs it (later, when a is done). I ran the first part (the partial a).
  5. (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.
  • require is synchronous and cached. exports is only a shortcut to module.exports.
  • In Node, .mjs is ESM, .cjs is CommonJS, and .js follows package.json "type". Set it explicitly. ESM needs full file extensions.
  • import() is async and works in both. Top-level await makes a module async, and require() 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

Part of the study guide

Related series