DEV Community

Alex Chen
Alex Chen

Posted on

JavaScript Modules: ES Modules, CommonJS, and How They Actually Work (2026)

JavaScript Modules: ES Modules, CommonJS, and How They Actually Work (2026)

Modules are the foundation of every modern JavaScript project. Understanding how they work will save you hours of debugging.

The Two Module Systems

// === CommonJS (CJS) — Node.js original ===
// Uses require() and module.exports
// Synchronous: loads at runtime

// Exporting:
// myModule.js
const name = "My Module";
const data = [1, 2, 3];

function greet(who) {
  return `Hello, ${who} from ${name}!`;
}

// Named exports (object):
module.exports = { name, data, greet };

// Or single export:
module.exports = class UserService {
  // ...
};

// Importing:
const { greet, data } = require('./myModule');
const UserService = require('./UserService');

// Destructuring is optional — can import the whole object:
const myModule = require('./myModule');
myModule.greet("Alice");

// === ES Modules (ESM) — Modern standard ===
// Uses import/export
// Static: analyzed at compile time, supports tree-shaking

// Exporting:
// myModule.mjs (or .js with "type": "module" in package.json)
export const name = "My Module";
export const data = [1, 2, 3];

export function greet(who) {
  return `Hello, ${who} from ${name}!`;
}

// Default export (one per file):
export default class UserService {
  // ...
}

// Re-exporting (barrel files):
export { greet } from './greet.js';
export { fetchUser } from './api.js';
export { default as Utils } from './utils.js';

// Importing:
import { greet, data } from './myModule.mjs';
import UserService from './UserService.mjs';   // Default import
import * as MyModule from './myModule.mjs';      // Namespace import (everything)

// Dynamic import (works in both CJS and ESM!):
const heavyModule = await import('./heavy-module.mjs');
heavyModule.doSomething();
Enter fullscreen mode Exit fullscreen mode

How Each System Actually Works

CommonJS Execution Model:
┌─────────────────────────────┐
│  require('./foo') called    │
│         ↓                  │
│  Read and PARSE foo.js     │  ← Happens at runtime
│  Wrap in function wrapper:  │
│  (function(exports, require,│
│    module, __filename,      │
│    __dirname) { ... })()    │
│         ↓                  │
│  EXECUTE code in foo.js    │  ← Runs synchronously
│         ↓                  │
│  Return module.exports     │
└─────────────────────────────┘

Key insight: Every CJS file gets wrapped in a function.
That's why `require`, `exports`, `module` are available — they're arguments!

ES Module Execution Model:
┌─────────────────────────────┐
│  import { x } from './foo'  │
│         ↓                  │
│  Parse and ANALYZE imports │  ← Compile time (static)
│  Build dependency graph     │
│         ↓                  │
│  Download all dependencies  │  ← Can be async!
│         ↓                  │
│  INSTANTIATE each module    │  ← Link exports to imports
│         ↓                  │
│  EVALUATE code in order     │  ← Depth-first, post-order
└─────────────────────────────┘

Key insight: ESM has three distinct phases:
1. Construction (find & link)
2. Evaluation (execute top-level code)
3. This happens ONCE per module (cached after first load!)
Enter fullscreen mode Exit fullscreen mode

Interoperability (Mixing CJS and ESM)

// === Importing CJS in ESM ===
// package.json has no "type": "module" → treated as CJS

// In an ESM file (.mjs or "type": "module"):
import pkg from './cjs-module.cjs';        // Works! default = module.exports
import { namedExport } from './cjs-module.cjs'; // ⚠️ Only works if CJS used named exports!

// ⚠️ Gotcha: CJS module.exports = { a: 1 }
// import { a } from './cjs.cjs' → UNDEFINED!
// Because CJS doesn't have real named exports.
// Workaround: use namespace import + destructure:
import cjs from './cjs-module.cjs';
const { a } = cjs; // Now it works!

// === Importing ESM in CJS ===
// This does NOT work directly:
// const esm = require('./esm-module.mjs'); // ❌ Error!

// Options:
// 1. Dynamic import (returns Promise):
async function loadEsm() {
  const mod = await import('./esm-module.mjs'); // Works in CJS too!
  mod.default.doSomething();
}

// 2. Use .cjs extension for CommonJS files:
// utils.cjs → always CJS, regardless of package.json "type"
// utils.mjs → always ESM, regardless of package.json "type"

// === Best Practice: Decide ONE system per project ===
// New projects: ESM ("type": "module" in package.json)
// Legacy projects: CJS (no "type" field, or "type": "commonjs")
// Libraries: Consider dual publish (both CJS and ESM support)
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

// === Pattern 1: Barrel/Index Files ===
// components/index.js (ESM):
export { Button } from './Button.jsx';
export { Input } from './Input.jsx';
export { Modal } from './Modal.jsx';

// Usage:
import { Button, Input, Modal } from './components';
// Clean imports! One line instead of three.

// === Pattern 2: Lazy Loading (Performance!) ===
// Don't import heavy modules until needed:

// ❌ Eager (loads everything at startup):
import HeavyChartLibrary from './chart-library.js';
import PDFGenerator from './pdf-generator.js';

function renderChart(data) {
  HeavyChartLibrary.render(data);
}
function generatePDF(content) {
  PDFGenerator.create(content);
}

// ✅ Lazy (only loads when function is called):
async function renderChart(data) {
  const { default: Chart } = await import('./chart-library.js');
  Chart.render(data);
}

async function generatePDF(content) {
  const { default: PDF } = await import('./pdf-generator.js');
  PDF.create(content);
}

// Great for route-based splitting (React, Vue, etc.):
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));

// === Pattern 3: Conditional Imports ===
// Load different modules based on environment:

let database;
if (process.env.NODE_ENV === 'test') {
  database = await import('./database/mock.js');
} else if (process.env.DB_TYPE === 'postgres') {
  database = await import('./database/postgres.js');
} else {
  database = await import('./database/sqlite.js');
}

// === Pattern 4: Circular Dependencies (Avoid Them!) ===
// A.js imports B.js, B.js imports A.js → circular!

// Why it's bad:
// A.js: const b = require('./B'); console.log(b.value); // undefined?!
// B.js: const a = require('./A'); a.value = 10;

// B gets A's module.exports BEFORE A finishes executing.
// So when B runs, A hasn't set value yet.

// Solutions:
// 1. Restructure (best): Move shared code into C.js that both A and B import
// 2. Delay access: Put the circular-requiring code inside a function (not top-level)
// 3. Dependency injection: Pass the dependency as argument instead of importing

// === Pattern 5: Mocking Modules for Testing ===
// test/setup.js — mock external services:
jest.mock('../services/api', () => ({
  fetchData: jest.fn().mockResolvedValue({ items: [] }),
  postUpdate: jest.fn(),
}));

// test/UserService.test.js:
import { userService } from '../services/userService.js';
import { api } from '../services/api.js';

test('fetches user data', async () => {
  api.fetchData.mockResolvedValueOnce({ id: 1, name: 'Test' });
  const user = await userService.getUser(1);
  expect(user.name).toBe('Test');
});
Enter fullscreen mode Exit fullscreen mode

package.json Configuration

{
  "name": "my-project",
  "version": "1.0.0",

  // THIS FIELD CHANGES EVERYTHING:
  "type": "module",

  // "type": "module"  All .js files are ESM (use import/export)
  // No "type" field / "type": "commonjs"  All .js files are CJS (use require/exports)

  // File extensions override the "type" setting:
  // .cjs  Always CommonJS (even with "type": "module")
  // .mjs  Always ESM (even without "type": "module")

  "main": "./dist/index.cjs",          // CJS entry point
  "module": "./dist/index.mjs",        // ESM entry point
  "types": "./dist/index.d.ts",        // TypeScript types

  "exports": {
    ".": {
      "import": "./dist/index.mjs",    // ESM consumers
      "require": "./dist/index.cjs",   // CJS consumers
      "types": "./dist/index.d.ts"     // TypeScript
    },
    "./utils": {
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  },

  // For tools (bundler, test runner):
  "sideEffects": false,                // Enables tree-shaking!

  "engines": {
    "node": ">=18.0.0"                 // Requires native ESM support
  }
}
Enter fullscreen mode Exit fullscreen mode

Which module system do you prefer? Ever been bitten by a CJS/ESM interop issue?

Follow @armorbreak for more practical developer guides.

Top comments (0)