JavaScript Modules | ES Modules and CommonJS Explained

Key takeaways

JavaScript modules: ES import/export vs CommonJS require, browser type=module, dynamic import(), bundlers (Webpack vs Vite), barrels, and Node type: module setup.

Introduction

A module splits code into reusable units with explicit boundaries: each file declares what it exposes and what it depends on, instead of everything sharing one global scope. JavaScript has two module systems in common use, ES Modules and Node’s older CommonJS, and most confusion comes from mixing them. This post compares the two, then covers modules in the browser, dynamic import(), a few practical module layouts, how bundlers fit in, and the pitfalls that show up most often (circular imports, missing extensions, duplicate defaults).


CommonJS vs ES Modules at a glance

CommonJS (CJS)ES Modules (ESM)
Syntaxrequire(), module.exportsimport, export
Load timerequire at runtime (path can vary)Top-level import is static (great for tree shaking)
Typical useLegacy Node and npm packagesBrowser standard; Node with “type”: “module”
Top-level thismodule.exportsundefined (module scope is strict)
  • New projects: prefer ESM; fall back to dynamic import() or interop for older packages.
  • Node: whether a file is CJS or ESM is determined by package.json "type" and extensions (.cjs / .mjs).

The “static” row is the difference that everything else follows from. An ESM file is parsed, and all of its import declarations are found and fetched, before any of its code runs. That is why import must appear at the top level (not inside an if), why the specifier must be a string literal, and why bundlers can remove exports nobody imports. CommonJS require() is just a function: it can be called inside a condition, with a computed path, and it synchronously reads and executes the file the moment it is reached. That flexibility is also why CJS cannot be tree-shaken reliably and cannot load over the network in a browser.


ES Modules

export

// math.js
// Named exports (many allowed)
export function add(a, b) {
    return a + b;
}
export function subtract(a, b) {
    return a - b;
}
export const PI = 3.14159;
// Export list
function multiply(a, b) {
    return a * b;
}
function divide(a, b) {
    return a / b;
}
export { multiply, divide };
// Rename on export
function power(a, b) {
    return a ** b;
}
export { power as pow };

import

// main.js
// Named imports
import { add, subtract, PI } from './math.js';
console.log(add(10, 20));      // 30
console.log(subtract(20, 10)); // 10
console.log(PI);               // 3.14159
// Rename on import
import { pow as power } from './math.js';
console.log(power(2, 3));  // 8
// Namespace import
import * as math from './math.js';
console.log(math.add(5, 3));  // 8
console.log(math.PI);         // 3.14159
// Side-effect import (initialization only)
import './polyfills.js';
// Re-export from another module
// export { add } from './math.js';
// export { default as User } from './user.js';

Imported names are live, read-only bindings, not copies. If math.js later reassigns an exported let counter, every importer sees the new value, but an importer cannot assign to it: PI = 3 in main.js throws TypeError: Assignment to constant variable. Each module is also evaluated once per realm no matter how many files import it; the second import './math.js' reuses the same instance, which is what makes modules usable as singletons (and why module-level state is shared across the whole app). The side-effect import import './polyfills.js' runs a module purely for what it does at load time, and its position matters, since imports are evaluated in order.

Default export

// user.js
// One default export per module
export default class User {
    constructor(name, email) {
        this.name = name;
        this.email = email;
    }
    
    greet() {
        console.log(`Hello, ${this.name}!`);
    }
}
// Or
class User {
    // ...
}
export default User;
// Functions work too
export default function greet(name) {
    console.log(`Hello, ${name}!`);
}

These are three alternative forms; a real file would contain only one of them, since a second export default is a syntax error (see pitfall 3). A default export is really just a named export called default, which is why import { default as User } from './user.js' works and why re-exporting needs the export { default as User } form shown earlier.

// main.js
// Default import (local name is yours)
import User from './user.js';
const user = new User("Alice", "[email protected]");
user.greet();  // Hello, Alice!
// Default + named together
import User, { formatDate, validateEmail } from './user.js';

Choosing default vs named

default exportnamed export
CountOne per fileMany
Import nameCan rename freely (import MyThing)Names are fixed (use as for aliases)
Refactors / IDEEasier to drift per fileStable, easier to track
Good forSingle React component, “one main thing”Utility collections, constants, types

Tip: libraries often favor named exports; app code sometimes uses default for a page component. Mixing both in one file is valid.

The argument against defaults is practical. Because the importer picks the name, the same component can be imported as User, UserCard and Profile in three files, and a project-wide search for usages finds none of them reliably. Named exports keep one name everywhere, auto-import in editors works better, and a typo such as import { captialize } fails at link time with SyntaxError: The requested module './utils.js' does not provide an export named 'captialize', whereas a mistyped default import silently gets whatever the default is. Some frameworks require defaults (Next.js pages, React.lazy), which is the main reason app code still uses them.


CommonJS (Node.js)

module.exports

// math.js
function add(a, b) {
    return a + b;
}
function subtract(a, b) {
    return a - b;
}
const PI = 3.14159;
// Option 1: export an object
module.exports = {
    add,
    subtract,
    PI
};
// Option 2: attach properties
module.exports.add = add;
module.exports.subtract = subtract;
module.exports.PI = PI;
// Option 3: exports shorthand
exports.add = add;
exports.subtract = subtract;

exports is simply a local variable that starts out pointing at the same object as module.exports, and require() returns module.exports. Adding properties through either name works. Reassigning exports = { add } does not: it only rebinds the local variable, and the module still exports the original empty object, so the caller gets {} and add is not a function. If you replace the whole export object, always assign to module.exports. Also note that option 1 after options 2/3 would discard their properties; pick one style per file.

require

// main.js
// Whole module
const math = require('./math.js');
console.log(math.add(10, 20));  // 30
console.log(math.PI);           // 3.14159
// Destructuring
const { add, subtract } = require('./math.js');
console.log(add(10, 20));  // 30
// Built-ins
const fs = require('fs');
const path = require('path');
const http = require('http');

require() caches each module by its resolved file path in require.cache, so the second call returns the same object without re-running the file. Destructuring takes a snapshot: const { counter } = require('./state') copies the value at that moment, and later changes inside the module are not visible, which is the opposite of ESM’s live bindings. Node also resolves bare paths for you: require('./math') tries math.js, math.json, math.node and then math/index.js. That convenience is exactly what ESM drops, as pitfall 2 shows. In current Node versions, prefer the node: prefix for built-ins (require('node:fs')) so they cannot be confused with an npm package of the same name.

ESM vs CJS

ES ModulesCommonJS
Syntaximport / exportrequire / module.exports
RuntimesBrowsers + NodeNode (legacy default)
LoadingStatic (parse time)Dynamic (runtime)
Async loading✅ (import())❌
Tree shaking✅❌
Extensions.mjs or "type" in package.json.js (CJS default)

Interop: importing CJS from ESM may wrap the namespace under default depending on tooling. Pure-ESM packages consumed from CJS may need import() or Node’s createRequire.

Interop is where most real-world module pain lives. In Node, import pkg from 'cjs-package' always works and gives you module.exports as the default; named imports such as import { readFile } from 'cjs-package' work only when Node’s static analysis (cjs-module-lexer) can detect the export names, so some packages need the default-import form. In the other direction, require() of an ES module used to fail with ERR_REQUIRE_ESM; recent Node releases (22.12+, and 20.19+) can require() an ES module as long as it does not use top-level await, but older versions still need await import(). I have seen plenty of CI breakages after a dependency’s major version went ESM-only, and the fix was almost always one of these two forms rather than any change to the dependency.


Modules in the browser

type="module"

<!DOCTYPE html>
<html>
<head>
    <title>ES Modules</title>
</head>
<body>
    <h1>Module Test</h1>
    <button id="loadBtn">Load heavy module</button>
    
    <script type="module">
        import { add, subtract } from './math.js';
        
        console.log(add(10, 20));  // 30
        
        // Dynamic import
        const button = document.querySelector('#loadBtn');
        button.addEventListener('click', async () => {
            const module = await import('./heavy-module.js');
            module.doSomething();
        });
    </script>
</body>
</html>

Module scripts behave differently from classic scripts in ways that surprise people the first time:

  • They are deferred automatically: the script runs after the document is parsed, so querySelector finds elements below the script tag.
  • They run in strict mode with their own scope; top-level const and functions do not become globals, so inline onclick="add()" handlers in the HTML cannot see them.
  • They are fetched with CORS. Opening the page from file:// fails with a CORS error in the console; serve it with any local HTTP server (npx serve, python -m http.server).
  • Relative specifiers need the full file name, and bare specifiers like import _ from 'lodash' fail with Failed to resolve module specifier "lodash" unless you add an import map or use a bundler.

Dynamic import()

// Conditional loading
async function loadModule(moduleName) {
    if (moduleName === 'math') {
        const math = await import('./math.js');
        return math;
    }
}
const math = await loadModule('math');
console.log(math.add(5, 3));
// Code splitting
button.addEventListener('click', async () => {
    const { default: Chart } = await import('./chart.js');
    new Chart('#myChart');
});

import() is an expression that returns a promise for the module’s namespace object, and unlike static import, it can appear anywhere and take a computed string. The { default: Chart } destructuring is needed because a default export lives on the default property of the namespace. The top-level await loadModule(...) works only in an ES module (top-level await is not allowed in classic scripts or CommonJS). The usual trade-off is latency: code loaded on click is downloaded on click, so the first interaction waits for the network. Bundlers let you prefetch such chunks (/* webpackPrefetch: true */, or <link rel="modulepreload">) when the user is likely to need them.


Practical examples

Example 1: Utility modules

// utils/string.js
export function capitalize(str) {
    return str.charAt(0).toUpperCase() + str.slice(1);
}
export function truncate(str, maxLength) {
    if (str.length <= maxLength) return str;
    return str.slice(0, maxLength) + '...';
}
export function slugify(str) {
    return str
        .toLowerCase()
        .replace(/\s+/g, '-')
        .replace(/[^\w-]/g, '');
}
// utils/array.js
export function chunk(arr, size) {
    const result = [];
    for (let i = 0; i < arr.length; i += size) {
        result.push(arr.slice(i, i + size));
    }
    return result;
}
export function unique(arr) {
    return [...new Set(arr)];
}
export function shuffle(arr) {
    const copy = [...arr];
    for (let i = copy.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [copy[i], copy[j]] = [copy[j], copy[i]];
    }
    return copy;
}
// utils/index.js (barrel)
export * from './string.js';
export * from './array.js';
// Or selective re-exports
export { capitalize, truncate } from './string.js';
export { chunk, unique } from './array.js';
// main.js
import { capitalize, chunk } from './utils/index.js';
console.log(capitalize("hello"));  // Hello
console.log(chunk([1, 2, 3, 4, 5], 2));  // [[1, 2], [3, 4], [5]]

A barrel file gives consumers one import path, which is nice for a small utility folder. It has two costs that grow with the project. Importing one function from a barrel makes the runtime (and the dev server, and test runners like Jest) load every module the barrel re-exports, which is a known cause of slow test startup and slow dev-server page loads in large apps. And export * silently skips a name exported by two modules (the conflicting name is simply not exported), so a collision shows up only when someone imports that name, as a SyntaxError about an ambiguous or missing export. Production bundlers can usually tree-shake through barrels if modules are side-effect free ("sideEffects": false in package.json), but dev tooling often cannot.

Example 2: API client

// api/client.js
const BASE_URL = 'https://api.example.com';
export class APIClient {
    constructor(apiKey) {
        this.apiKey = apiKey;
    }
    
    async request(endpoint, options = {}) {
        const url = `${BASE_URL}${endpoint}`;
        const headers = {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${this.apiKey}`,
            ...options.headers
        };
        
        const response = await fetch(url, { ...options, headers });
        
        if (!response.ok) {
            throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }
        
        return await response.json();
    }
    
    get(endpoint) {
        return this.request(endpoint, { method: 'GET' });
    }
    
    post(endpoint, data) {
        return this.request(endpoint, {
            method: 'POST',
            body: JSON.stringify(data)
        });
    }
    
    put(endpoint, data) {
        return this.request(endpoint, {
            method: 'PUT',
            body: JSON.stringify(data)
        });
    }
    
    delete(endpoint) {
        return this.request(endpoint, { method: 'DELETE' });
    }
}
export default APIClient;
// api/users.js
import APIClient from './client.js';
export class UserAPI {
    constructor(apiKey) {
        this.client = new APIClient(apiKey);
    }
    
    async getUsers() {
        return await this.client.get('/users');
    }
    
    async getUser(id) {
        return await this.client.get(`/users/${id}`);
    }
    
    async createUser(userData) {
        return await this.client.post('/users', userData);
    }
    
    async updateUser(id, userData) {
        return await this.client.put(`/users/${id}`, userData);
    }
    
    async deleteUser(id) {
        return await this.client.delete(`/users/${id}`);
    }
}
// main.js
import { UserAPI } from './api/users.js';
const api = new UserAPI('your-api-key');
async function main() {
    try {
        const users = await api.getUsers();
        console.log(users);
        
        const newUser = await api.createUser({
            name: "Alice",
            email: "[email protected]"
        });
        console.log("Created:", newUser);
    } catch (error) {
        console.error("Error:", error);
    }
}
main();

The layering here is the point: client.js owns transport details (base URL, headers, error handling), users.js owns the resource endpoints, and main.js only sees domain methods. Because BASE_URL is a module-level constant that is not exported, nothing outside client.js can depend on it, which is the encapsulation modules give you without classes or closures. Exporting APIClient both as a named and a default export is legal, but in a codebase it is better to choose one, for the reasons given in the default-versus-named section. One real-world caution: an API key passed from browser code is visible to every user, so this pattern with a secret key belongs in server-side code.


Bundlers and builds: Webpack, Vite

Browsers can load ESM natively, but production apps usually use a bundler because:

  • Bundle many files and npm packages into one (or few) assets
  • Minify, split chunks (often aligned with dynamic import())
  • Transpile TypeScript/JSX, process CSS imports, etc.

Webpack

  • Role: walk the dependency graph from entry and emit bundles; extend with loaders and plugins.
  • Fit: very configurable; still common for large legacy codebases.
  • Dev: webpack-dev-server, HMR, etc.

Vite

  • Role: dev server uses native ESM for speed; production builds often use Rollup under the hood.
  • Fit: minimal config, great DX with Vue/React templates; import analysis feels natural.

Quick chooser

SituationPick
New SPA, fast prototypeStart with Vite
Deep investment in Webpack pluginsKeep Webpack or migrate gradually
Publishing a libraryRollup / tsup, etc.

Dynamic import() typically becomes a separate chunk in both Webpack and Vite.


ES modules in Node.js

package.json

{
  "name": "my-project",
  "version": "1.0.0",
  "type": "module"
}

Or use the .mjs extension:

// math.mjs
export function add(a, b) {
    return a + b;
}
// main.mjs
import { add } from './math.mjs';
console.log(add(10, 20));

With "type": "module", every .js file in that package is treated as ESM, and CommonJS-only features disappear: require, module.exports, __dirname and __filename are undefined. Use import.meta.url with fileURLToPath, or import.meta.dirname in Node 20.11+, instead of __dirname. Config files for older tools that still expect CommonJS then need the .cjs extension. The reverse also holds: without "type", .js means CommonJS, and only .mjs files are ESM. Mixing the two in one package is possible, but deciding per package rather than per file avoids a lot of confusion.


Common pitfalls

Pitfall 1: circular dependencies

// ❌ Circular
// a.js
import { b } from './b.js';
export const a = 'A';
// b.js
import { a } from './a.js';
export const b = 'B';
// ✅ Fix structure
// common.js
export const a = 'A';
export const b = 'B';
// a.js
import { b } from './common.js';
// b.js
import { a } from './common.js';

The first pair actually loads without error in ESM, because it only exports constants and never reads the other module’s binding while loading. The problem appears as soon as one module uses an import during its own evaluation. When main.js imports a.js, the engine first evaluates b.js (a’s dependency); if b.js runs console.log(a) at top level, a.js has not executed yet and you get ReferenceError: Cannot access 'a' before initialization. Uses inside functions called later are fine, because by then both modules have finished. In CommonJS the same cycle is quieter and worse: require('./a') inside b.js returns a partially filled module.exports, so values are simply undefined. Moving the shared pieces into a third module, as above, removes the cycle; tools like madge --circular find cycles in larger codebases.

Pitfall 2: wrong paths

// ❌ Missing extension (browser)
import { add } from './math';  // Error!
// ✅ Include extension
import { add } from './math.js';
// Node may resolve extensionless paths (CommonJS)
const math = require('./math');  // OK

ESM resolution treats a specifier as a URL, so there is no guessing of extensions or index.js. Node reports it as ERR_MODULE_NOT_FOUND: Cannot find module '.../math' imported from .../main.js, and a browser gets a 404 for /math. This catches many people moving a CommonJS project to "type": "module". TypeScript adds a twist: with "moduleResolution": "nodenext", you write import './math.js' in a .ts file even though the source is math.ts, because the specifier must match the emitted JavaScript. Bundlers such as Vite and Webpack do resolve extensionless paths, which is why code that works in the bundler can fail when run directly by Node.

Pitfall 3: multiple defaults

// ❌ Only one default
export default function add() {}
export default function subtract() {}  // SyntaxError
// ✅ Use named exports
export function add() {}
export function subtract() {}