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).
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.jsonwith"type": "module"or use.mjsfile extension. - When
"type":"module"is set, files ending with.jsare interpreted as ESM. Otherwise they are CommonJS by default (pre-Node 14 behavior). - ESM modules have different globals —
__filename/__dirnameare not available by default; useimport.meta.urlor 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.exportsor mutateexports.
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 whatrequire()returns — usemodule.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
}
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
exportsfield 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