Skip to main content
JavaScript · lesson 17 of 24
JavaScript intermediate Lesson 17 of 24

Also part of: TypeScript

JavaScript Modules: ESM and CommonJS

Understand ES modules and CommonJS, their syntax differences, dynamic imports for code splitting, and how bundlers like Vite and webpack fit in.

By SkillByExample · Updated

A module is a file with its own scope that chooses what to share. JavaScript has two module systems: ES modules (import/export, the standard) and CommonJS (require/module.exports, Node’s original). Every example below is a set of real files run with node; Node treats .mjs files as ES modules and .cjs files as CommonJS regardless of any config.

Named Exports

A module can export any number of values by name, and importers pick the ones they want.

// math.mjs
export const PI = 3.14159265358979;
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
const secret = "not exported";
console.log("math.mjs evaluated");
// app.mjs
import { add, PI } from "./math.mjs";
import { add as plus } from "./math.mjs";
import * as MathUtils from "./math.mjs";

console.log(add(2, 3), plus(2, 3), PI.toFixed(2));
console.log(Object.keys(MathUtils));
console.log(MathUtils.secret);
$ node app.mjs
math.mjs evaluated
5 5 3.14
[ 'PI', 'add', 'subtract' ]
undefined

math.mjs was imported three times but evaluated once — modules are singletons, cached after the first load. as renames on import (useful when two modules export the same name), and import * as collects every export into a namespace object. secret isn’t in it: anything not exported is private to the file.

Default Exports

A module can also have one default export — its main value — which the importer names freely.

// logger.mjs
export default class Logger {
  constructor(prefix) { this.prefix = prefix; }
  log(msg) { console.log(`[${this.prefix}] ${msg}`); }
}
export const LEVELS = ["info", "error"];
// main.mjs
import Logger, { LEVELS } from "./logger.mjs";
import WhateverIWant from "./logger.mjs";
import * as ns from "./logger.mjs";

new Logger("App").log(`started with levels ${LEVELS.join("/")}`);
console.log(Logger === WhateverIWant, Logger === ns.default);
$ node main.mjs
[App] started with levels info/error
true true

The default export is really just an export named default — ns.default is the same class. Because each importer picks the name, a default export can be imported under different names in different files, which makes renames and “find all usages” harder. Many teams prefer named exports for that reason.

Re-exports (Barrel Files)

An index file can re-export from several modules so consumers have one import path.

// utils/string.mjs
export const capitalize = (s) => s[0].toUpperCase() + s.slice(1);
// utils/index.mjs
export { add, PI } from "../math.mjs";
export { capitalize } from "./string.mjs";
export { default as Logger } from "../logger.mjs";
// barrel.mjs
import { capitalize } from "./utils/index.mjs";
console.log(capitalize("only capitalize was imported"));
$ node barrel.mjs
math.mjs evaluated
Only capitalize was imported

The consumer doesn’t know or care which file each name lives in. Notice math.mjs evaluated printed even though barrel.mjs only asked for capitalize: importing from a barrel loads every module the barrel re-exports. That’s harmless here, but in a large library a barrel can drag in far more code than you use — which is the problem tree shaking (below) solves at build time.

CommonJS

CommonJS assigns the public API to module.exports and loads it with a synchronous require().

// counter.cjs
let count = 0;
function increment() { count += 1; }
module.exports = { count, increment, getCount: () => count };
// counter.mjs
export let count = 0;
export function increment() { count += 1; }
// compare.mjs
import cjs from "./counter.cjs";
import { count, increment } from "./counter.mjs";

cjs.increment();
cjs.increment();
console.log("CommonJS count:", cjs.count, "| getCount():", cjs.getCount());

increment();
increment();
console.log("ESM count:", count);
$ node compare.mjs
CommonJS count: 0 | getCount(): 2
ESM count: 2

This is the deepest difference between the two systems. module.exports = { count } copied the value 0 into an object; incrementing the module’s variable later doesn’t change the copy, so only a getter sees 2. An ES module export is a live binding — the importer’s count is a read-only view of the exporting module’s variable, so it shows 2. Also note ESM can import a CommonJS file: its module.exports object arrives as the default export.

Going the Other Way: require() an ES Module

Since Node 22.12, CommonJS can require() an ES module too, as long as it has no top-level await.

// legacy.cjs
const math = require("./math.mjs");
const { add } = require("./math.mjs");
console.log(add(20, 22), typeof math.subtract);
const { default: Logger } = require("./logger.mjs");
new Logger("cjs").log("default export via .default");
$ node legacy.cjs
math.mjs evaluated
42 function
[cjs] default export via .default

require returns the module’s namespace object, so a default export has to be read from .default. Older Node versions threw ERR_REQUIRE_ESM here, which is why so many packages used to ship two builds.

ES modulesCommonJS
Syntaximport / exportrequire() / module.exports
Bindingslivecopied values
Loadingstatic, resolved before runningruns when require() is called
Top-level awaityesno
Browsersnativeneeds a bundler
Node file extensions.mjs, or .js with "type": "module".cjs, or .js by default

To make plain .js files ES modules for a whole package, add "type": "module" to its package.json; any CommonJS file in that package then needs the .cjs extension.

Dynamic import()

import() is an expression that loads a module on demand and returns a promise for its namespace.

// heavy-chart.mjs
console.log("  (heavy-chart.mjs loading now)");
export function renderChart(data) { return data.map((n) => "#".repeat(n)).join("\n"); }
// lazy.mjs
console.log("app started");

async function onOpenChartTab(data) {
  const { renderChart } = await import("./heavy-chart.mjs");
  return renderChart(data);
}

console.log(await onOpenChartTab([3, 5, 2]));
console.log("second open:");
console.log(await onOpenChartTab([1]));

const page = "barrel";
await import(`./${page}.mjs`);
$ node lazy.mjs
app started
  (heavy-chart.mjs loading now)
###
#####
##
second open:
#
math.mjs evaluated
Only capitalize was imported

The chart module didn’t load at startup — only when first needed — and the second import() reused the cached module, so it didn’t log again. The specifier can be computed at runtime (./${page}.mjs), something a static import declaration can never do. In the browser, bundlers turn each dynamic import() into a separate file that is downloaded only when that line runs: code splitting.

What a Bundler Does With This

For browsers you usually run a bundler such as Vite (or webpack, Rollup, esbuild). Here is a real Vite build of a two-module app:

// shop/math.js
export const add = (a, b) => a + b;
export const subtractUnusedEverywhere = (a, b) => a - b;
// shop/main.js
import { add } from "./math.js";
document.body.textContent = `total: ${add(2, 3)}`;
document.body.onclick = async () => {
  const { renderChart } = await import("./chart.js");
  document.body.textContent = renderChart([3, 5]);
};
// shop/chart.js
export const renderChart = (data) => data.map((n) => "#".repeat(n)).join(" ");
<!-- shop/index.html -->
<script type="module" src="/main.js"></script>
$ cd shop && npx vite build --logLevel warn && ls dist/assets && grep -c subtractUnusedEverywhere dist/assets/*.js
chart-DvOuFD-a.js
index-Bqh_kbet.js
dist/assets/chart-DvOuFD-a.js:0
dist/assets/index-Bqh_kbet.js:0

Two things happened. The dynamically imported chart.js became its own file, fetched only when the user clicks. And subtractUnusedEverywhere appears nowhere in the output (grep -c counts 0 in both files): because ES imports are static, the bundler could prove nothing uses it and dropped it — tree shaking. With CommonJS’s runtime require() calls that proof isn’t possible, which is why browser-bound code should use ES modules. (The hashes in the file names change whenever the content does, so browsers can cache the files forever.)

Key Takeaways

  • ES modules are the standard: import/export, evaluated once and cached.
  • Anything not exported is private to the file.
  • ESM exports are live bindings; CommonJS module.exports holds copied values.
  • .mjs is always ESM and .cjs always CommonJS; "type": "module" decides what .js means.
  • import() loads on demand and is how bundlers split code; static imports let them tree-shake.

Practice

1. Add a multiply export to a module and import it under the name times.
// ops.mjs
export const multiply = (a, b) => a * b;
// p1.mjs
import { multiply as times } from "./ops.mjs";
console.log(times(6, 7));
$ node p1.mjs
42
2. Give the CommonJS counter a live-looking count using a getter, so cjs.count reports 2.
// counter2.cjs
let count = 0;
module.exports = {
  increment() { count += 1; },
  get count() { return count; },
};
// p2.cjs
const counter = require("./counter2.cjs");
counter.increment();
counter.increment();
console.log(counter.count);
$ node p2.cjs
2

A getter re-reads the variable on every access — the CommonJS way to imitate a live binding.

3. Choose a formatter module at runtime from a variable, using import().
// fmt-upper.mjs
export default (s) => s.toUpperCase();
// fmt-reverse.mjs
export default (s) => [...s].reverse().join("");
// p3.mjs
for (const style of ["upper", "reverse"]) {
  const { default: format } = await import(`./fmt-${style}.mjs`);
  console.log(style, "->", format("modules"));
}
$ node p3.mjs
upper -> MODULES
reverse -> seludom

With import() the default export arrives as the default property of the namespace object, so destructure it and rename it.

4. Prove a module is evaluated only once even when two other modules import it.
// shared.mjs
console.log("shared.mjs evaluated");
export const createdAt = Math.random();
// a.mjs
export { createdAt as a } from "./shared.mjs";
// b.mjs
export { createdAt as b } from "./shared.mjs";
// p4.mjs
import { a } from "./a.mjs";
import { b } from "./b.mjs";
console.log("same value:", a === b);
$ node p4.mjs
shared.mjs evaluated
same value: true

One evaluation and one random number, shared by both importers — the property the Singleton pattern relies on.

Frequently Asked Questions

Can I mix ESM and CommonJS in the same project?
In Node.js you can interop them with care: ESM can import CJS modules using import, and since Node 22.12 (and 20.19) CJS can require() an ES module that has no top-level await; older versions throw ERR_REQUIRE_ESM. Bundlers like webpack and Vite abstract away most of this friction. In the browser, only ESM is natively supported.
What is tree shaking and why does it require ESM?
Tree shaking is a bundler optimization that removes unused exports from the final bundle. It works because ESM imports and exports are static — they can be analyzed at build time. CommonJS require() is dynamic and can appear inside conditions, so bundlers cannot statically determine what is unused.
What is the difference between a named export and a default export?
A named export uses a fixed identifier (export const foo = ...) and is imported by that exact name with curly braces. A default export (export default ...) is the module's primary value and is imported without curly braces, and the importer can choose any name for it.