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 fundamental to modern JavaScript. Understanding how they work saves you from mysterious bugs and build failures.

The Two Module Systems

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

// Exporting:
// math.js
function add(a, b) { return a + b; }
function multiply(a, b) { return a * b; }

module.exports = { add, multiply };
// OR: exports.add = add;
// OR: module.exports = add; (single export)

// Importing:
const { add, multiply } = require('./math');
const utils = require('./utils');

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

// Exporting:
// math.mjs (or package.json "type": "module")
export function add(a, b) { return a + b; }
export function multiply(a, b) { return a * b; }

// Named export (multiple per file):
export const PI = 3.14159;

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

// Re-exporting from another module:
export { add, multiply } from './math.js';
export * from './utils.js'; // Re-export everything

// Importing:
import { add, multiply } from './math.js';
import Calculator from './Calculator.js';
import * as math from './math.js'; // Namespace import

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

Key Differences That Matter

┌───────────────────────┬─────────────────────┬──────────────────────┐
│ Feature               │ CommonJS            │ ES Modules           │
├───────────────────────┼─────────────────────┼──────────────────────┤
│ Syntax                │ require() / exports │ import / export       │
│ Loading              │ Synchronous         │ Asynchronous          │
│ this keyword          │ {} (empty object)   │ undefined             │
│ Top-level await       │ ❌ No               │ ✅ Yes                │
│ Tree shaking         │ ❌ No               │ ✅ Yes (static analysis)│
│ Cyclic dependencies  │ Partially works     │ Works with live bindings│
│ __dirname, __filename│ Available           ❌ Not available        │
│ require()            │ Can use anywhere    │ Only top level        │
│ Dynamic import()      │ Not needed          │ import() for lazy load│
│ this in modules      │ Points to module.exports | Points to undefined│
│ Node.js support      │ Always supported    │ Requires .mjs or "type":"module"│
└───────────────────────┴─────────────────────┴──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Interop: Mixing CJS and ESM

// Importing CommonJS in ESM:
import { createServer } from 'http'; // CJS module imported into ESM
// Works! Node.js provides interop.

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

// ✅ Use dynamic import instead:
async function main() {
  const { foo } = await import('./esm-module.mjs');
  foo();
}
main();

// Package.json configuration:
{
  "name": "my-package",
  "type": "module",              // Treats all .js files as ESM!
  // OR use explicit extensions:
  // "main": "./index.cjs",       // For CJS consumers
  // "exports": {
  //   ".": {
  //     "import": "./index.mjs", // For ESM consumers
  //     "require": "./index.cjs" // For CJS consumers
  //   }
  // },
  "exports": {
    ".": "./index.js",           // Conditional exports based on consumer type
    "./utils": "./utils.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Module Resolution: How Node Finds Your Files

// When you write: import './utils'
// Node looks for (in order):

// 1. ./utils.mjs (if type: "module")
// 2. ./utils.js
// 3. ./utils/index.mjs
// 4. ./utils/index.js
// 5. ./utils.json (with --experimental-json-modules)

// When you write: import 'lodash'
// Node looks for (in order):

// 1. node_modules/lodash/package.json → "main" or "exports" field
// 2. node_modules/lodash/index.js
// 3. node_modules/lodash/index.cjs
// 4. Walk up parent directories looking for node_modules/

// ⚠️ Common pitfalls:

// Missing extension:
import './utils';     // Might fail without .js extension in strict ESM!
import './utils.js';  // Explicit is safer

// Directory imports:
import './components'; // Looks for components/index.js
// NOT components.js (common mistake!)

// File case sensitivity:
import './MyComponent'; // Different from mycomponent on Linux!
// On macOS they're the same (case-insensitive), on Linux they're not.
// This causes "works on my machine" bugs!

// Bare specifiers (no relative path):
import 'react';        // Resolved via node_modules (or package.json imports)
import '#config';      // Requires package.json "imports" mapping:
// "imports": { "#config": "./config.js" }
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

// Pattern 1: Barrel files (re-exports)
// components/index.js
export { Button } from './Button.jsx';
export { Input } from './Input.jsx';
export { Modal } from './Modal.jsx';
export { Card } from './Card.jsx';

// Now you can:
import { Button, Card } from './components'; // Clean imports!

// Pattern 2: Lazy loading (reduce initial bundle size)
// Instead of:
import HeavyChart from './HeavyChart'; // Loaded immediately (even if not used!)

// Use:
const Chart = React.lazy(() => import('./HeavyChart'));
// Only fetched when first rendered!

// In your component:
<Suspense fallback={<Spinner />}>
  <Chart data={data} />
</Suspense>

// Pattern 3: Dynamic imports by condition
if (process.env.NODE_ENV === 'development') {
  const DevTools = await('./DevTools');
  DevTools.init();
}

// Pattern 4: Module mock pattern (for testing)
// jest.config.js or inline:
jest.mock('./api', () => ({
  fetchUsers: jest.fn().mockResolvedValue(mockUsers),
  createUser: jest.fn().mockResolvedValue(newUser),
}));

// Pattern 5: Singleton pattern with modules
// config.js (ESM)
let config = null;

export function getConfig() {
  if (!config) {
    config = JSON.parse(readFileSync('/etc/app/config.json', 'utf-8'));
  }
  return config;
}
// First import reads file, subsequent calls return cached value
// (Module is only evaluated once!)

// Pattern 6: Circular dependency handling
// A.js imports B.js, B.js imports A.js
// ❌ Problematic (CJS gets partial export, ESM works better):

// A.js
export function functionFromA() { ... }
import { functionFromB } from './B.js';

// B.js
export function functionFromB() { ... }
import { functionFromA } from './A.js';

// ESM handles this correctly due to live bindings!
// CJS: B.js might get an empty object for A's exports
// Fix for CJS: defer the usage of circular imports inside functions
Enter fullscreen mode Exit fullscreen mode

Debugging Module Issues

# "Module not found" errors:
# Check: Is the path correct? Case sensitive? Extension included?

# "Cannot use import outside a module":
# Fix: Add "type": "module" to package.json
# OR rename file to .mjs
# OR use dynamic import()

# "require is not defined":
# You're using ESM syntax but file is treated as CJS
# Fix: Add "type": "module" to package.json

# "Cannot find module X":
# npm install X                    # Is it installed?
# ls node_modules/X                 # Does it exist?
# cat node_modules/X/package.json   # Does it have correct main/exports?

# Check what Node resolves:
node -e "console.log(require.resolve('express'))"
# Or for ESM:
node --input-type=module -e "import.meta.resolve('express').then(console.log)"

# Clear cache (sometimes stale cache causes issues):
rm -rf node_modules/.cache
# Or restart Node process

# Check if file is CJS or ESM:
head -1 myfile.js
# If it has "use strict"; or require() → likely CJS
# If it has import/export → must be ESM
Enter fullscreen mode Exit fullscreen mode

Which module system do you prefer? Have you hit any weird interop issues?

Follow @armorbreak for more practical developer guides.

Top comments (0)