THN Interview Prep

CommonJS vs ES Modules in Node.js: A Comprehensive Comparison for Senior Developers

The transition from CommonJS to ECMAScript Modules (ESM) represents one of the most significant architectural shifts in the Node.js ecosystem. Understanding the differences, trade-offs, and modern best practices for both systems is essential for senior developers working on new projects, maintaining legacy codebases, or planning migrations in 2026.

1. Fundamental Differences

AspectCommonJS (CJS)ES Modules (ESM)
Syntaxrequire() / module.exportsimport / export
Module loadingSynchronousAsynchronous (static analysis possible)
Evaluation timingAt runtimeStatic (hoisted, evaluated before execution)
Top-level awaitNot supportedSupported
Circular dependency handlingSupported (partial resolution)Better (live bindings)
File extension requirementOptional (.js, .cjs, .node)Required (.js, .mjs) or type: "module"
Default export styleSingle value (module.exports = …)Named exports + optional export default
Dynamic importNot native (workarounds exist)Native (await import(…) )
Tree-shakingDifficult / limitedExcellent (static structure)
Browser compatibilityRequires bundlersNative in modern browsers

2. Loading and Execution Behavior

CommonJS

// Synchronous resolution and execution
const fs = require("fs");
const util = require("./utils");

module.exports = { readFile: fs.readFileSync };
  • require() blocks until the module is fully loaded and executed
  • Circular dependencies are resolved using partial objects (can lead to incomplete state)

ES Modules

// Static imports (hoisted)
import fs from "node:fs/promises";
import { formatDate } from "./utils.js";

export const readConfig = async () => {
  const data = await fs.readFile("config.json", "utf8");
  return JSON.parse(data);
};

// Top-level await is allowed
const config = await readConfig();
  • All import statements are evaluated before any module code runs
  • Static analysis enables tree-shaking, better dead-code elimination, and bundler optimizations

Modern best practice for libraries that need to support both ecosystems:

// package.json
{
  "name": "my-awesome-lib",
  "type": "module", // Default to ESM
  "main": "./dist/index.cjs", // For CommonJS consumers
  "module": "./dist/index.js", // For ESM-aware bundlers
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js", // ESM
      "require": "./dist/index.cjs" // CommonJS
    },
    "./package.json": "./package.json"
  },
  "engines": {
    "node": ">=18"
  }
}

This pattern (conditional exports + dual builds) is a common choice for libraries that must support both CommonJS and ESM consumers. It adds complexity, so use it when external compatibility justifies the maintenance cost.

4. Migration Patterns and Challenges

Common migration strategies observed in large codebases:

StrategyWhen to UseDifficultyDowntime Risk
Full ESM migrationGreen-field projects, new librariesMediumLow
Dual support (conditional exports)Public libraries, transitional periodHighLow
type: "module" + dynamic importGradual migration of existing large codebaseMediumMedium
createRequire bridgeAccessing ESM from legacy CommonJS codeLowLow
Full rewrite to ESMWhen heavy technical debt existsVery HighHigh

Most common pain points during migration:

  • require()import conversion (especially dynamic paths)
  • __dirname / __filename replacement (import.meta.url)
  • JSON imports (import data from './data.json' assert { type: 'json' })
  • Top-level await usage
  • Third-party CommonJS-only dependencies
  • Jest/Mocha → native test runner or Vitest transition

Migration playbook

  1. Inventory runtime version, test runner, bundler, and top dependencies.
  2. Decide whether the boundary is app-only ESM, library dual-publish, or incremental mixed mode.
  3. Convert leaf modules before shared infrastructure modules.
  4. Replace __dirname/__filename with import.meta.url helpers.
  5. Remove dynamic path-based require() where static imports are possible.
  6. Use createRequire only as an explicit bridge.
  7. Validate startup, tests, CLI entry points, and production packaging.

Common mistakes

  • Flipping "type": "module" at the repo root before tools and dependencies are ready.
  • Publishing dual entry points that load two singleton instances in the same process.
  • Forgetting file extensions in ESM imports.
  • Treating tree shaking as a runtime optimization for unbundled Node services.
  • Hiding migration risk behind broad transpilation without testing actual runtime resolution.

5. Practical Recommendations (2026 Perspective)

ScenarioRecommended Approach
New application / microserviceESM only ("type": "module")
Public npm libraryDual package with conditional exports
Large legacy enterprise codebaseIncremental migration + createRequire bridge
Performance-critical hot pathESM (better tree-shaking + static analysis)
Need top-level awaitESM (no alternative in CommonJS)
Maximum compatibility with old Node versionsCommonJS (Node < 12.17 / 14 without flags)

Summary - Quick Decision Table

New project in 2026?                                 → Use ESM
Maintaining library used by others?                  → Dual package (conditional exports)
Working on 5+ year old enterprise codebase?          → Gradual migration + bridge pattern
Need top-level await or dynamic import()?            → Must use ESM
Prioritizing maximum [tree-shaking](./tree-shaking) & bundler support? → ESM
Must support very old Node versions (<12)?           → CommonJS

The Node.js ecosystem has decisively moved toward ES Modules as the future standard. While CommonJS is not deprecated and will continue to be supported for years, new code written in 2026 should default to ESM unless a specific compatibility constraint forces otherwise.

Senior developers who can confidently design dual-support packages, execute large-scale ESM migrations, and explain the subtle behavioral differences between the two systems demonstrate deep platform expertise that remains highly valued in modern Node.js development.

Interview answer structure

“For new Node apps I usually default to ESM if the runtime and tooling support it. For public libraries, I decide whether consumers still need CJS and, if so, use conditional exports carefully. For legacy services, I migrate module boundaries incrementally, keep tests running in the real runtime mode, and use createRequire only as a transition bridge.”

Follow-ups to expect:

  • What is the dual package hazard?
  • Why does ESM require different handling for __dirname?
  • How do conditional exports affect consumers?
  • When would you intentionally keep CommonJS?

Mark this page when you finish learning it.

Spotted something unclear or wrong on this page?

On this page