JavaScript modules: import/export, CommonJS vs es modules
JavaScript modules split code into separate files, each with its own scope. Two systems exist: CommonJS (require/module.exports), the original Node.js default, and ES Modules (import/export), the current web standard.
Theory
TL;DR
- Analogy: modules are like restaurant kitchens. Each exports dishes through a menu (exports), and other kitchens import what they need without sharing raw ingredients. No global scope pollution.
- CommonJS loads synchronously at runtime. ES Modules parse statically at load time.
- ESM enables tree-shaking; CommonJS does not.
- Node 14+ supports both. Browsers only support ESM natively.
- Decision rule: use ESM for all new code. CommonJS only for legacy Node codebases.
Quick example
Same math utilities, two module formats:
// CommonJS
const PI = 3.14159;
function add(a, b) { return a + b; }
module.exports = { PI, add };
const math = require('./math.cjs');
console.log(math.add(math.PI, 1)); // 4.14159
// ES Modules
export const PI = 3.14159;
export function add(a, b) { return a + b; }
import { PI, add } from './math.mjs';
console.log(add(PI, 1)); // 4.14159ESM lets you import exactly what you need by name. CommonJS returns the whole module.exports object and you destructure it yourself.
Key difference
ES Modules build a static dependency graph at parse time. Bundlers like Webpack and Rollup read that graph before any code runs, so they drop unused exports (tree-shaking). CommonJS resolves require() calls at runtime, meaning the bundler cannot know ahead of time what code is actually used. That single difference explains why migrating a library from CJS to ESM can shrink bundle sizes noticeably. ESM also supports top-level await; CommonJS cannot, because synchronous loading and async simply do not mix.
When to use
- Browser project or Vite/Webpack: ESM. Native browser support, tree-shaking, no extra config.
- Node.js 14+: ESM with
.mjsextension or"type": "module"inpackage.json. - Node.js older than 14, or code that needs
require(variable)at runtime: CommonJS. - Bridging the two: dynamic
import()works from both CommonJS and ESM files.
Comparison table
| Feature | ES Modules | CommonJS |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Loading | Static, parse-time | Synchronous, runtime |
| Browser support | Native | Needs bundler |
| Tree-shaking | Yes | No |
Top-level await | Yes | No |
| Default export | export default foo | module.exports = foo |
| Dynamic imports | await import(variable) | require(variable) directly |
| Node.js default | No (needs .mjs or config) | Yes (pre-v14) |
| When to use | React 18+, Node 20+, all new code | Legacy Express 4.x, Node < 14 |
How the engine handles this
V8 parses ESM at load time, builds a static dependency graph, and links export bindings before evaluation. Node.js checks the "type" field in package.json first, then the file extension: .mjs forces ESM, .cjs forces CommonJS. CommonJS uses require.cache to store loaded modules, so calling require('./math') twice returns the same cached module.exports object without re-executing the file.
Common mistakes
Forgetting the .js extension in Node ESM:
import { PI } from './math'; // Error: Cannot find module
import { PI } from './math.js'; // WorksNode ESM requires explicit file extensions. CommonJS infers them. This catches almost everyone migrating from CJS.
Using require inside an ESM file:
// .mjs file, or package.json with "type": "module"
const fs = require('fs'); // SyntaxError: require is not defined in ES module scope
import fs from 'node:fs'; // CorrectMultiple default exports:
export default foo;
export default bar; // SyntaxError: only one default export per moduleUse named exports when you need both.
Exporting before defining in CommonJS:
module.exports.add = add; // ReferenceError: add is not defined
function add(a, b) { return a + b; } // define first
module.exports = { add }; // then exportESM handles this through hoisting automatically.
Expecting tree-shaking from CommonJS in a bundler:
// Bundler cannot remove unusedFn here
module.exports.unusedFn = function() {};
module.exports.usedFn = function() {};
// Switch to ESM named exports for dead code eliminationReal-world usage
- React 18+:
import React from 'react'- tree-shaken, ESM standard. - Node 20 + Express 5:
"type": "module"inpackage.json,import express from 'express'. - Vite (SvelteKit, Next.js): native ESM, dynamic
import()for route-level code splitting. - Webpack 5: ESM output by default, handles CommonJS inputs.
- Deno and Bun: ESM-only runtimes, zero config required.
Follow-up questions
Q: How does tree-shaking work in ESM but not CommonJS?
A: ESM exports are static names known at parse time. Bundlers trace which names are actually imported and drop the rest. In CommonJS, module.exports is a plain object assigned at runtime, so the bundler cannot determine what is used without executing the code.
Q: What happens with circular module dependencies?
A: ESM partially resolves export bindings before evaluation, so most circular imports work. CommonJS may return an empty {} for the not-yet-finished module, which causes runtime errors that are difficult to trace.
Q: How do you enable ESM in Node.js?
A: Add "type": "module" to package.json or use the .mjs extension. To force CommonJS explicitly, use .cjs.
Q: How does dynamic import() work?
A: const mod = await import('./math.js') returns a Promise of the module namespace object. It works inside both ESM and CommonJS files, which makes it the standard way to bridge mixed codebases.
Q: Can browsers use ESM without a bundler?
A: Yes. <script type="module" src="./main.js"></script> works in all modern browsers. You need relative paths with explicit extensions (./math.js), not bare specifiers (math).
Senior Q: Walk through what V8 actually does when it encounters an import statement, and why that enables top-level await.
A: V8 creates a ModuleRecord at parse time, fetches and resolves all dependencies asynchronously (building a full graph), then links each exported name to a live binding before any module code runs. Because the entire linking phase is async, the engine can suspend evaluation waiting for await without blocking the thread. CommonJS require() is a synchronous function call - it runs the file inline and returns whatever module.exports holds at that moment. No async phase means no top-level await.
Examples
Named and default exports
// userService.js
export const DEFAULT_ROLE = 'viewer'; // named export
export function createUser(name, role = DEFAULT_ROLE) {
return { name, role, id: Date.now() };
}
export default class UserService { // default export
async getUser(id) {
return { id, name: 'Alice', role: 'admin' };
}
}// main.js
import UserService, { createUser, DEFAULT_ROLE } from './userService.js';
const service = new UserService();
const user = await service.getUser(1);
// { id: 1, name: 'Alice', role: 'admin' }
const newUser = createUser('Bob');
// { name: 'Bob', role: 'viewer', id: 1718000000000 }Named and default exports coexist in one file. The default is for the module's main thing; named exports are for utilities alongside it.
Dynamic imports and barrel files
// services/index.js (barrel file)
export { default as UserService } from './userService.js';
export { default as ProductService } from './productService.js';
export * from './utils.js';
// app.js - cleaner import paths
import { UserService, ProductService } from './services/index.js';Barrel files simplify import paths across a codebase. Vite and Webpack 5 tree-shake through them correctly. Older bundlers may pull in more than expected, so check bundle output if you add many of them.
Dynamic imports for on-demand loading:
// Load a heavy module only when actually needed
async function handleExport(data) {
const { generatePDF } = await import('./pdfGenerator.js');
return generatePDF(data);
}
// React lazy loading - same mechanism
const ReportPage = React.lazy(() => import('./pages/ReportPage.js'));Circular dependency behavior
This separates candidates who have read the module spec from those who only know the syntax.
// a.mjs
import { fnB } from './b.mjs';
export function fnA() { return fnB(); }
// b.mjs
import { fnA } from './a.mjs';
export function fnB() { return 'B called'; }
// main.mjs
import { fnA } from './a.mjs';
console.log(fnA()); // 'B called' - ESM handles this correctlyESM resolves this because exports are live bindings. By the time fnA actually calls fnB, the binding is already set up. The CommonJS equivalent breaks: require('./b') during loading of a returns {} because b has not finished yet. I have seen this produce silent undefined is not a function errors in Express apps where two service files imported each other - the kind of bug that takes an afternoon to find.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.