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.
Before modules existed, all JavaScript shared one global scope. Libraries attached variables to window, name collisions were common, and dependency order in <script> tags mattered. Modules solve this by giving each file its own scope with explicit imports and exports — you can only use what a file deliberately shares, and every dependency is clearly declared at the top of the file.
ES Modules (ESM) — The Standard
ES modules are the official JavaScript module system, supported natively in modern browsers and Node.js 12+. They use static import and export declarations, which means the dependency graph is known before any code runs — a property that enables tree shaking and other build-time optimizations.
Named Exports
Named exports let a module share multiple values under explicit names. Importers pick exactly what they need, which helps bundlers eliminate everything else.
// math.js — each export is individually named
export const PI = 3.14159265358979;
export function add(a, b) {
return a + b;
}
export function subtract(a, b) {
return a - b;
}
export class Vector {
constructor(x, y) {
this.x = x;
this.y = y;
}
magnitude() {
return Math.sqrt(this.x ** 2 + this.y ** 2);
}
}
// app.js — import only what you need; bundlers tree-shake the rest
import { add, PI, Vector } from "./math.js";
console.log(add(2, 3)); // 5
console.log(PI); // 3.14159...
const v = new Vector(3, 4);
console.log(v.magnitude()); // 5
Default Export
Each module can have one default export — typically its main class or function. Default exports are useful when a module has one clear primary purpose and the importer should be free to name it whatever makes sense in context.
// logger.js — Logger is the main thing this module provides
export default class Logger {
constructor(prefix) {
this.prefix = prefix;
}
log(msg) {
console.log(`[${this.prefix}] ${msg}`);
}
error(msg) {
console.error(`[${this.prefix}] ERROR: ${msg}`);
}
}
// import default — the importer chooses the local name
import Logger from "./logger.js";
import AppLogger from "./logger.js"; // same module, different local name — both valid
const log = new Logger("App");
log.log("Started");
Import Aliases and Namespace Imports
Aliases prevent name collisions when two modules export identically named values. Namespace imports bundle all exports into a single object, which is convenient when you need many exports from one module.
// Rename on import to avoid clashes with local variables
import { add as addNumbers, subtract as subtractNumbers } from "./math.js";
// Import everything under a namespace object — useful for utility libraries
import * as MathUtils from "./math.js";
console.log(MathUtils.PI);
console.log(MathUtils.add(1, 2));
// Mix default and named in one import statement
import Logger, { formatMessage } from "./logger.js";
Re-exports (Barrel Files)
A barrel file re-exports from many modules in one place, giving consumers a single import path for an entire feature or library. This keeps import statements clean without forcing every module to know the internal file structure.
// utils/index.js — aggregate exports from sub-modules
export { add, subtract, PI } from "./math.js";
export { formatDate, parseDate } from "./date.js";
export { capitalize, truncate } from "./string.js";
export { default as Logger } from "./logger.js";
// consumers import from one path regardless of internal structure
import { add, Logger, formatDate } from "./utils/index.js";
CommonJS (CJS) — Node.js Classic
CommonJS is the original Node.js module system, predating the ESM standard. It uses synchronous require() calls, which made sense for a server environment reading from disk, but doesn’t work in the browser where modules must load asynchronously. CJS is still common in older packages and server-only Node.js code.
// math.cjs
const PI = 3.14159265358979;
function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
// Assign the public API to module.exports
module.exports = { PI, add, subtract };
// OR export individual properties incrementally
module.exports.multiply = (a, b) => a * b;
// app.cjs — require() executes and caches the module synchronously
const { PI, add } = require("./math.cjs");
const fs = require("fs"); // built-in modules work the same way
const path = require("path");
console.log(add(2, 3)); // 5
ESM vs CJS Side by Side
Understanding the differences helps you choose the right format and debug interop issues.
| Feature | ESM | CommonJS |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Loading | Static, asynchronous | Dynamic, synchronous |
| Tree shaking | Yes | No |
| Top-level await | Yes | No |
| Browser native | Yes | No |
| Node.js | Yes (.mjs or "type":"module") | Yes (default) |
| Circular deps | Handled (live bindings) | Handled (partial object) |
// ESM
import defaultExport from "./module.js";
import { named } from "./module.js";
export const value = 42;
export default function main() {}
// CommonJS equivalent
const defaultExport = require("./module.js");
const { named } = require("./module.js");
module.exports.value = 42;
module.exports = function main() {};
Dynamic Import
Static import declarations are hoisted and always loaded upfront. Dynamic import() returns a Promise and loads the module on demand — this is the foundation of code splitting. Instead of one large bundle, users download only the code they actually need for the page they’re viewing.
// Load a heavy charting library only when the user opens the chart view
async function renderChart(data) {
// Chart.js is NOT included in the initial bundle — it downloads here on demand
const { Chart } = await import("chart.js");
const ctx = document.getElementById("myChart").getContext("2d");
return new Chart(ctx, {
type: "bar",
data,
});
}
// Conditionally load polyfills — only pay the cost when the feature is missing
if (!window.IntersectionObserver) {
await import("intersection-observer");
}
// Route-based code splitting in a vanilla SPA — each page is a separate chunk
async function navigate(route) {
const { default: page } = await import(`./pages/${route}.js`);
page.render();
}
Module Bundlers
In production you rarely ship raw ESM to the browser — bundlers combine modules, apply tree shaking, minify output, and handle legacy browser compatibility. They also turn dynamic import() calls into automatic split points so the browser can load chunks in parallel.
Vite (recommended for new projects):
// vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
build: {
rollupOptions: {
output: {
// Split vendor libraries into a separate chunk — better long-term caching
manualChunks: {
vendor: ["react", "react-dom"],
},
},
},
},
});
webpack (battle-tested, highly configurable):
// webpack.config.js
module.exports = {
entry: "./src/index.js",
output: { filename: "bundle.[contenthash].js" }, // content hash for cache busting
optimization: {
splitChunks: { chunks: "all" }, // automatic code splitting for shared dependencies
},
};
Both tools resolve import statements, apply tree shaking, and output optimised bundles. Dynamic import() calls become automatic split points with no extra configuration.
Node.js Module Configuration
// package.json — declare ESM for the whole package
{
"type": "module"
}
With "type": "module", all .js files are treated as ESM. Use .cjs extension for any CommonJS files in the same package. Without it, .js files are CJS and you use .mjs for ESM files.
Key Takeaways
- ESM is the standard: use
import/exportfor all new code. - Default exports are for a module’s primary value; named exports for everything else.
- Dynamic
import()enables on-demand loading and is the foundation of code splitting. - Tree shaking only works with static ESM — avoid CommonJS in browser-targeted code.
- Vite and webpack both understand ESM and handle the interop automatically.