CommonJS and ES Modules Compared: Syntax, Semantics, UMD, and Edge Cases
CommonJS and ES Modules are the dominant JavaScript module systems in use today, with UMD frequently employed as a compatibility layer for libraries that target both Node.js and browsers.
Module formats in practice
- UMD (Universal Module Definition)
- CommonJS (primarily Node.js)
- ES Modules (standardized in ECMAScript)
UMD wrapper (browser + Node)
(function (globalScope, factory) {
if (typeof module === 'object' && module.exports) {
// Node/CommonJS
module.exports = factory();
} else if (typeof define === 'function' && define.amd) {
// AMD
define([], factory);
} else {
// Browser globals
globalScope.Library = factory();
}
}((typeof self !== 'undefined' ? self : this), function () {
'use strict';
const api = { version: '1.0.0' };
return api;
}));
CommonJS (Node.js)
- Export patterns
- module.exports is the actual exported value
- exports is a convenience alias that initially references module.exports
// file: util.js
// Property-style exports
module.exports.count = 1;
module.exports.bump = function () { module.exports.count += 1; };
exports.msg = 'hello';
// Replacing the entire export object
module.exports = {
count: 1,
msg: 'hello',
bump() { this.count += 1; }
};
// Pitfall: reassigning `exports` breaks the link with `module.exports`
exports = { // ❌ does not change what the module actually exports
count: 1,
msg: 'hello'
};
- Import via require
// file: main.js
const util = require('./util');
console.log(util.count); // 1
util.bump();
console.log(util.count); // 2
- Cyclic dependencies in CommonJS CommonJS executes modules immediately on first require, and exports are ordinary values at the moment of assignment. During a cycle, a module may observe a partially initialized export from its dependency.
// file: alpha.js
exports.value = 'alpha:v1';
const beta = require('./beta');
console.log('alpha sees beta.flag =', beta.flag);
exports.value = 'alpha:v2';
// file: beta.js
exports.flag = 'beta:v1';
const alpha = require('./alpha');
console.log('beta sees alpha.value =', alpha.value);
exports.flag = 'beta:v2';
// file: main.js
const alpha = require('./alpha');
console.log('main sees alpha.value =', alpha.value);
ES Modules (ESM)
Core export forms
// Re-export everything
export * from './other.js';
// Re-export specific bindings (renaming allowed)
export { a, b as bAlias } from './other.js';
// Named exports from local declarations
export const x = 1, y = 2;
export function add(n, m) { return n + m; }
export class Box {}
// Default export (expression)
export default function greet(name) { return `hi ${name}`; }
// Default export as a live binding via rename
let counter = 0;
function inc() { counter += 1; }
export { counter as default, inc };
Core import forms
// Named imports
import { x, y as why } from './math.js';
// Default import
import greet from './greet.js';
// Namespace import
import * as pkg from './pkg.js';
// Mixed
import def, { x as x1, add } from './mod.js';
// Side-effect-only import (no bindings pulled in)
import './polyfill.js';
// Dynamic import (asynchronous)
const modPromise = import('./feature.js');
modPromise.then(mod => mod.run());
Static structure constraints
- import and export are only valid at the module’s top level
- Bindings are resolved statically at parse/compile time
- Names in import/export cannot be computed dynamically
Invalid examples
// ❌ Not allowed inside blocks or control flow
if (true) {
import { x } from './m.js';
export const y = 1;
}
// ❌ Not allowed: computed or string-literal as the imported name
const name = 'x';
import { [name] } from './m.js';
import 'defaultName' from './m.js';
Live bindings and read-only imports
- All ESM modules run in strict mode
- Imported bindings are read-only views into the exporter’s variables
- Updates in the exporting module are observable by importers
// file: counter.mjs
export let ticks = 0;
export function tick() { ticks += 1; }
// file: main.mjs
import { ticks, tick } from './counter.mjs';
console.log(ticks); // 0
tick();
console.log(ticks); // 1
// file: snap.mjs
let score = 0;
export default score; // default export of the current value (snapshot)
export function inc() { score += 1; }
// file: consume.mjs
import value, { inc } from './snap.mjs';
console.log(value); // 0
inc();
console.log(value); // 0 (default export of expression is not a live binding)
// file: live-default.mjs
let level = 0;
export { level as default }; // live binding for default via rename
export function bump() { level += 1; }
// file: use-live.mjs
import lvl, { bump } from './live-default.mjs';
console.log(lvl); // 0
bump();
console.log(lvl); // 1
Cyclic dependencies in ES Modules ESM initializes the module graph in dependency order and wires live bindings before evaluation. Accessing an imported binding before the exporter initializes it triggers a temporal dead zone error or yields uninitialized state. Avoid reading imports at top level during a cycle; delay use to a function.
Problematic pattern
// file: a.mjs
import { bVal } from './b.mjs';
console.log('a sees bVal =', bVal); // ⚠️ may throw if bVal not initialized
export const aVal = 'A';
// file: b.mjs
import { aVal } from './a.mjs';
console.log('b sees aVal =', aVal); // ⚠️ may throw if aVal not initialized
export const bVal = 'B';
Safe pattern (defer usage)
// file: a.mjs
import { ping } from './b.mjs';
export function pong() { return 'pong'; }
export function runA() { return `A got: ${ping()}`; }
// file: b.mjs
import { pong } from './a.mjs';
export function ping() { return `ping + ${pong()}`; }
// file: main.mjs
import { runA } from './a.mjs';
console.log(runA());
Key differences: CommonJS vs ES Modules
- Binding semantics
- CommonJS exports are snapshots of values at the time of assignment; reassignment requires writing back to module.exports
- ES Modules export live bindings (except export default expression, which is a value). export { name as default } creates a live default binding
- Syntax and timing
- CommonJS is dynamic; require can be used anywhere and is synchronous
- ES Modules are static at parse time; import/export must be top-level, with module loading and evaluation order managed by the engine. Dynamic import() is asynchronous
- Multiplicity of exports
- CommonJS has a single exported value (module.exports)
- ES Modules support multiple named exports plus a optional default
- this at top level
- In CommonJS modules, top-level this is module.exports (in Node’s wrapper)
- In ES Modules, top-level this is undefined