Skip to main content

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:

javascript
// 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.14159

ESM 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 .mjs extension or "type": "module" in package.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

FeatureES ModulesCommonJS
Syntaximport / exportrequire() / module.exports
LoadingStatic, parse-timeSynchronous, runtime
Browser supportNativeNeeds bundler
Tree-shakingYesNo
Top-level awaitYesNo
Default exportexport default foomodule.exports = foo
Dynamic importsawait import(variable)require(variable) directly
Node.js defaultNo (needs .mjs or config)Yes (pre-v14)
When to useReact 18+, Node 20+, all new codeLegacy 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:

javascript
import { PI } from './math'; // Error: Cannot find module import { PI } from './math.js'; // Works

Node ESM requires explicit file extensions. CommonJS infers them. This catches almost everyone migrating from CJS.

Using require inside an ESM file:

javascript
// .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'; // Correct

Multiple default exports:

javascript
export default foo; export default bar; // SyntaxError: only one default export per module

Use named exports when you need both.

Exporting before defining in CommonJS:

javascript
module.exports.add = add; // ReferenceError: add is not defined function add(a, b) { return a + b; } // define first module.exports = { add }; // then export

ESM handles this through hoisting automatically.

Expecting tree-shaking from CommonJS in a bundler:

javascript
// Bundler cannot remove unusedFn here module.exports.unusedFn = function() {}; module.exports.usedFn = function() {}; // Switch to ESM named exports for dead code elimination

Real-world usage

  • React 18+: import React from 'react' - tree-shaken, ESM standard.
  • Node 20 + Express 5: "type": "module" in package.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

javascript
// 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' }; } }
javascript
// 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

javascript
// 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:

javascript
// 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.

javascript
// 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 correctly

ESM 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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?