JavaScript Module Systems: CJS vs ESM vs UMD
JavaScript has evolved through multiple module systems. CommonJS (CJS) uses synchronous require() and is runtime-based. ES Modules (ESM) use static import/export syntax, enabling tree-shaking and better optimization. UMD is a legacy wrapper that works across environments. Understanding static vs runtime resolution is the key differentiator for performance and tooling.
CJS containers are opened and inspected only when the ship arrives at runtime (dynamic). ESM containers are scanned and optimized at the port (build time) with clear labels, allowing removal of unused cargo (tree-shaking). UMD is an old multi-format shipping crate that works everywhere but is bulky.
1CommonJS (CJS)
Legacy Node.js module system. Uses synchronous require(). Modules are loaded and executed at runtime. Dynamic imports are possible but prevents many build-time optimizations.
const add = (a, b) => a + b;
module.exports = { add };2ES Modules (ESM)
Modern standard. Uses static import/export. Enables tree-shaking, scope hoisting, and top-level await. Native support in modern browsers and Node (with type: module).
export const add = (a, b) => a + b;
export default { add };3UMD (Universal Module Definition)
Legacy pattern designed to work in browser, Node, and AMD environments. Usually generated by bundlers for library distribution. Larger output size and rarely used in modern apps.
(function(root, factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory();
} else {
root.myLib = factory();
}
}(typeof self !== 'undefined' ? self : this, function() {
return { /* lib */ };
}));4Bundler Role & Interop
Bundlers (Webpack, Vite, Rollup) resolve the module graph at build time. ESM enables powerful optimizations. Interop between CJS and ESM can be tricky — default exports from CJS become .default in ESM.
| Property | CommonJS (CJS) | ES Modules (ESM) | UMD |
|---|---|---|---|
| Loading | Synchronous | Async by default | Depends on environment |
| Behavior | Runtime resolution (synchronous require) | Static analysis (import/export) | Universal wrapper for multiple environments |
| Use Case | Legacy Node.js, server-side packages | Modern frontend & Node (type: module) | Legacy library distribution (CDN) |
| Tree Shaking | Not possible | Fully supported | Limited |
| Modern Status | Still common in Node ecosystem | Current standard | Mostly obsolete |
CommonJS (CJS)
Loading
Synchronous
Behavior
Runtime resolution (synchronous require)
Use Case
Legacy Node.js, server-side packages
Tree Shaking
Not possible
Modern Status
Still common in Node ecosystem
ES Modules (ESM)
Loading
Async by default
Behavior
Static analysis (import/export)
Use Case
Modern frontend & Node (type: module)
Tree Shaking
Fully supported
Modern Status
Current standard
UMD
Loading
Depends on environment
Behavior
Universal wrapper for multiple environments
Use Case
Legacy library distribution (CDN)
Tree Shaking
Limited
Modern Status
Mostly obsolete
Common questions
- ›“What is the difference between CJS and ESM?”
- ›“Why does tree-shaking only work with ESM?”
- ›“How do you handle CJS/ESM interop issues?”
- ›“When would you still use UMD?”
What interviewers look for
- Deep understanding of static vs runtime module resolution
- Knowledge of tree-shaking and bundler optimizations
- Practical interop strategies
- Modern best practices (ESM-first)
Short answer (60 sec)
ESM uses static imports for tree-shaking and optimization. CJS uses dynamic require() at runtime. UMD is a legacy wrapper for broad compatibility. Today we default to ESM.
Detailed answer (senior level)
The core difference is static (ESM) vs runtime (CJS) resolution. ESM allows bundlers to analyze the dependency graph at build time, enabling tree-shaking and scope hoisting. CJS executes code during require(), limiting optimizations. Interop is tricky because CJS modules export a single object while ESM has named + default exports. Senior engineers prefer ESM and use bundlers to handle legacy CJS packages.
- Mixing require() and import in the same file
- Expecting tree-shaking to work with CJS modules
- Default export confusion when importing CJS into ESM
- Forgetting to set "type": "module" in Node.js package.json
- Using UMD for new application code
- ✓ESM is the modern standard — use it by default
- ✓CJS is runtime-based and blocks tree-shaking
- ✓Static analysis in ESM enables powerful bundler optimizations
- ✓CJS ↔ ESM interop requires care (especially default exports)
- ✓UMD is mostly legacy for CDN library distribution