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)

Import and export seem simple until they're not. Here's what's actually happening under the hood.

The Two Systems

CommonJS (CJS): The old standard (Node.js default until 2022)
  → require() / module.exports
  → Synchronous (blocks execution while loading)
  → Dynamic (can require inside if statements)
  → Node.js .js files default to CJS

ES Modules (ESM): The modern standard
  → import / export
  → Asynchronous (loaded in parallel)
  → Static (must be at top level — compiler can analyze)
  → JavaScript standard (works in browsers too!)
  → Node.js "type": "module" in package.json enables ESM
Enter fullscreen mode Exit fullscreen mode

CommonJS (CJS)

// Exporting
// exports.js
const name = 'my-app';
const version = '1.0.0';

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

// Named exports (object assignment)
module.exports = {
  name,
  version,
  greet,
};

// Or single export
module.exports = function() {
  return 'Hello World';
};

// Or class export
module.exports = class Database {
  constructor(url) { this.url = url; }
  connect() { /* ... */ }
};

// Importing
// app.js
const { name, version, greet } = require('./exports');
// or: const app = require('./exports');

// Dynamic require (runtime conditional)
let config;
if (process.env.NODE_ENV === 'production') {
  config = require('./config/production');
} else {
  config = require('./config/development');
}

// Key behavior:
// → require() returns the EXPORTED OBJECT (a copy, not a reference)
// → Each require() caches the module (runs module code only ONCE)
// → module.exports = ... replaces the entire export object
// → exports.foo = ... adds to the export object (doesn't replace)
// → Don't mix! Use module.exports consistently
Enter fullscreen mode Exit fullscreen mode

ES Modules (ESM)

// Exporting
// utils.js

// Named exports (one per item)
export const PI = 3.14159;
export function add(a, b) { return a + b; }
export class Calculator { /* ... */ }
export default class App { /* ... */ } // One default per module

// Named export (list at bottom — cleaner)
const name = 'my-app';
const version = '1.0.0';
function greet(name) { return `Hello, ${name}!`; }

export { name, version, greet };

// Rename on export
export { greet as sayHello, name as appName };

// Re-export from another module
export { add, multiply } from './math.js';
export * from './constants.js'; // Export everything

// Importing
// app.js

// Default import
import App from './App.js';

// Named imports
import { add, PI } from './math.js';

// Rename on import
import { add as sum, PI as MathPi } from './math.js';

// Combine default + named
import React, { useState, useEffect } from 'react';

// Import everything as namespace
import * as math from './math.js';
math.add(2, 3); // namespace access

// Dynamic import (returns Promise — works anywhere!)
const module = await import('./heavy-module.js');
module.doSomething();

// Conditional dynamic import
if (featureEnabled) {
  const { premium } = await import('./premium.js');
  premium.unlock();
}

// ⚠️ CRITICAL: ESM import paths must include extension in Node.js
import { add } from './math.js';     // ✅ Correct
import { add } from './math';        // ❌ Missing extension (works in bundlers, not Node)

// With package.json "imports" field, you can avoid extensions:
// "imports": {
//   "#lib/*": "./src/lib/*.js"
// }
// Then: import { add } from '#lib/math';
Enter fullscreen mode Exit fullscreen mode

Key Differences That Matter

// 1. CJS: exports are COPIES
// ESM: exports are LIVE BINDINGS (references!)

// CJS version:
// counter.js
let count = 0;
function increment() { count++; return count; }
module.exports = { count, increment };

// main.js
const counter = require('./counter');
console.log(counter.count);  // 0
counter.increment();
console.log(counter.count);  // STILL 0! (copy, not reference)

// ESM version:
// counter.js
export let count = 0;
export function increment() { count++; return count; }

// main.js
import { count, increment } from './counter.js';
console.log(count);   // 0
increment();
console.log(count);   // 1! (live binding — sees the update!)

// 2. ESM is statically analyzed (compiler knows imports at parse time)
// CJS is dynamic (require() is just a function call)

// This means:
// → Tree-shaking works with ESM (unused exports are eliminated)
// → Bundlers can optimize ESM imports
// → CJS requires must be evaluated at runtime

// 3. Top-level await (ESM only!)
// In an ESM file, you can use await at the top level:
const data = await fetch('/api/config').then(r => r.json());
console.log(data);
// No wrapping in async IIFE needed!

// CJS: Must wrap in async function
(async () => {
  const data = await fetch('/api/config').then(r => r.json());
  console.log(data);
})();

// 4. __filename and __dirname don't exist in ESM
// CJS:
console.log(__filename); // /path/to/file.js
console.log(__dirname);  // /path/to

// ESM equivalent:
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Enter fullscreen mode Exit fullscreen mode

Interoperability (Mixing CJS and ESM)

This is where it gets confusing. Here's the rule table:

FROM → TO    |  CJS require()  |  ESM import
-------------|-----------------|------------
CJS module   |  ✅ Direct      |  ❌ Cannot import CJS with named exports*
ESM module   |  ❌ Cannot      |  ✅ Direct

* Exception: ESM can import CJS via default import:
  import pkg from './cjs-module.js';  // Works! Gets module.exports
  But cannot destructure:
  import { foo } from './cjs-module.js';  // ❌ Undefined!

Best practice:
→ If you control the codebase: Pick ONE system and use it everywhere
→ "type": "module" in package.json → all .js files are ESM
→ No "type" or "type": "commonjs" → all .js files are CJS
→ .mjs files are ALWAYS ESM (regardless of package.json)
→ .cjs files are ALWAYS CJS (regardless of package.json)
Enter fullscreen mode Exit fullscreen mode

Practical Patterns

// Pattern 1: Barrel exports (index.js as module entry point)
// src/index.js
export { UserService } from './services/user.js';
export { AuthService } from './services/auth.js';
export { OrderService } from './services/order.js';

// Now consumers can import from the folder:
import { UserService, AuthService } from './src/index.js';

// Pattern 2: Singleton pattern with modules
// database.js (ESM)
let instance = null;

export function getDatabase() {
  if (!instance) {
    instance = new Database(process.env.DB_URL);
  }
  return instance;
}
// Module code runs only ONCE, so this naturally creates a singleton

// Pattern 3: Dynamic imports for code splitting
async function loadEditor() {
  const { Editor } = await import('./editor.js');
  const editor = new Editor('#container');
  editor.mount();
}

// Only load the editor when user clicks the button:
document.getElementById('edit-btn').addEventListener('click', loadEditor);

// Pattern 4: Dependency injection with modules
// config.js — can be swapped for testing
export const config = {
  apiBase: process.env.API_URL || 'http://localhost:3000',
  timeout: parseInt(process.env.TIMEOUT) || 5000,
};

// Pattern 5: Conditional exports (package.json)
{
  "exports": {
    ".": {
      "import": "./dist/index.mjs",    // ESM consumers
      "require": "./dist/index.cjs",   // CJS consumers
      "types": "./dist/index.d.ts"      // TypeScript consumers
    },
    "./utils": "./dist/utils.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

Debugging Import Issues

Common error messages and fixes:

"Cannot use import statement outside a module"
→ Add "type": "module" to package.json
→ Or rename file to .mjs

"require() of ES Module not supported"
→ Use dynamic import: const mod = await import('./mod.mjs')

"Must use import to load ES Module"
→ Change require('./mod') to import mod from './mod.js'

"Module not found" when importing local file
→ Check the file extension (.js is required for Node.js ESM!)
→ Check the path is relative (starts with ./ or ../)

"Cannot find module X" for npm package
→ npm install the package
→ Check if it has "exports" field in its package.json

"Named export 'X' not found" when importing CJS
→ Import the default: import cjsModule from './cjs-file.js'
→ Then access: cjsModule.X
→ This is the biggest gotcha when mixing systems!
Enter fullscreen mode Exit fullscreen mode

CommonJS or ESM — which do you prefer and why?

Follow @armorbreak for more practical developer guides.

Top comments (0)