JavaScript Modules — ES6 Modules (ESM) vs CommonJS (CJS)

Focused, detailed explanations with runnable examples and Node interoperability notes.

Quick roadmap (sequence)

Follow this sequence in the page: Overview → ES6 Modules → CommonJS → Interop → Examples & Tips.

Overview

Modules let you split code into reusable pieces. JavaScript has two major module systems in common use:

  • ES6 Modules (ESM) — modern standardized modules (import / export). Supported in browsers and Node (with some considerations).
  • CommonJS (CJS) — the historical Node.js module system (require / module.exports).
Important: ESM is static (imports/exports are statically analyzable) while CommonJS is dynamic (runtime). This affects tooling, bundling, and how imports behave.

ES6 Modules (ESM) — deep dive

Syntax (named & default exports)

ESM uses export and import. Exports are part of the module's static interface.

// math.js
export function add(a, b) { return a + b; }
export const PI = 3.14159;

export default function multiply(a, b) { return a * b; }
// app.js
import multiply, { add, PI } from './math.js';

console.log(add(2,3));     // 5
console.log(multiply(2,3));// 6
console.log(PI);           // 3.14159

Key ESM characteristics

  • Static structure: Imports/exports are known at parse time — enables tree-shaking and faster tooling.
  • Live bindings: Imported bindings are references to the original value — if the exporter updates the value, importers see the update.
  • Asynchronous loading in browsers: <script type="module"> loads modules asynchronously by default.
  • File extensions: In browsers you must use exact paths (including .js), e.g. ./lib.js.

Live bindings example

// counter.js
export let count = 0;
export function inc(){ count++; }

// viewer.js
import { count, inc } from './counter.js';
console.log(count); // 0
inc();
console.log(count); // 1  <-- live binding shows new value

Dynamic import (code-splitting)

You can import modules dynamically at runtime using import(), which returns a Promise.

// dynamic usage
async function loadFeature(){
  const mod = await import('./feature.js');
  mod.doSomething();
}

Top-level await

In ESM (modern environments), you can use await at top level inside modules (Node & modern browsers) — handy for bootstrapping async initializations.

ESM in Node.js — practical notes

  • To enable ESM in Node: either use package.json with "type": "module" or use .mjs file extension.
  • When "type":"module" is set, files ending with .js are interpreted as ESM. Otherwise they are CommonJS by default (pre-Node 14 behavior).
  • ESM modules have different globals — __filename / __dirname are not available by default; use import.meta.url or construct equivalents.

CommonJS (CJS) — deep dive

Syntax

// utils.js
function greet(name){ return `Hello ${name}`; }
module.exports = { greet };

// server.js
const { greet } = require('./utils');
console.log(greet('Alice'));

Key CJS characteristics

  • Runtime/imperative: require() is a function call executed at runtime — dynamic imports are trivial.
  • Synchronous loading: In Node, require is synchronous (appropriate for server startup), not suitable for browser async loading without bundlers.
  • Module caching: Modules are cached after first require. Subsequent requires return the same object (unless manually busted).
  • Exports are a single object: You assign to module.exports or mutate exports.

Caching and mutation example

// data.js
module.exports = { value: 1 };

// a.js
const data = require('./data');
console.log(data.value); // 1
data.value = 2;

// b.js
const data2 = require('./data');
console.log(data2.value); // 2  (same cached object)

Common pitfalls

  • Assigning to exports = {...} won't change what require() returns — use module.exports = ... if you want to replace the export object entirely.
  • Circular dependencies can produce partially-initialized objects — design modules to avoid heavy initialization in module scope when circular refs exist.

Interoperability (ESM ↔ CJS)

Importing CommonJS from ESM (Node)

When an ESM module imports a CJS module, Node presents the CJS module's module.exports as the default export.

// cjs-module.js
module.exports = { greet: () => 'hi' };

// esm-loader.mjs (or .js with "type":"module")
import cjs from './cjs-module.js';
console.log(cjs.greet()); // 'hi'
// To get named access, use: const { greet } = cjs;

Importing ESM from CommonJS

In Node you cannot use static require() to load an ESM module. You must use dynamic import() or spawn a child process, or convert the module.

// commonjs-file.js
(async () => {
  const esm = await import('./esm-module.mjs');
  esm.default(); // or esm.namedExport()
})();

package.json "type" rule (summary)

{
  "name": "example",
  "type": "module"  // .js files are ESM; use .cjs for CommonJS files if needed
}
Practical rule: prefer ESM for new projects (better ecosystem interop, browser compatibility, tree-shaking). If you rely on many legacy Node packages that are CJS-only, keep them as CJS or use interop patterns.

Examples & Practical Tips

1) Export styles (ESM)

// lib.js
export const a = 1;
export function foo(){ return 'foo'; }
export default function(){ console.log('default export'); }

2) Replace module object (CJS)

// config.js
module.exports = function createConfig(env) { return { env }; };

// usage
const createConfig = require('./config');
console.log(createConfig('prod'));

3) Circular dependencies — pattern to mitigate

Export functions instead of values that require early initialization.

// a.js (ESM)
import { getB } from './b.js';
export function getA(){ return 'A'; }
console.log(getB());

// b.js
import { getA } from './a.js';
export function getB(){ return getA() + 'B'; }

4) Tooling notes

  • Bundlers (Webpack, Rollup, Vite) understand ESM well — tree-shaking works best with ESM.
  • When publishing packages, provide both ESM and CJS builds if you want maximum compatibility (use exports field in package.json to define entry points).

5) Recommended best practices

  • Prefer ESM for new code: clearer semantics, browser parity, and modern tooling advantages.
  • Avoid heavy side-effects at module top-level if your module might be imported in different ways (to reduce circular dependency issues).
  • When publishing libraries, document whether consumers should use ESM or CJS and consider dual-publishing.

Concise cheat-sheet

ESM:
  - Syntax: import / export
  - Static: yes (parse-time)
  - Loading: async in browser, Node supports with config
  - Use-case: modern apps, frontend + backend

CommonJS:
  - Syntax: require() / module.exports
  - Static: no (runtime)
  - Loading: synchronous (Node)
  - Use-case: Node scripts, legacy modules