Post

ESM and CommonJS in Node.js: The Boundary Matters More Than the Syntax

ESM and CommonJS in Node.js: The Boundary Matters More Than the Syntax

ES modules and CommonJS are often introduced as two sets of syntax:

1
2
3
4
5
6
7
// ESM
import { readFile } from "node:fs/promises";
export function load() {}

// CommonJS
const { readFile } = require("node:fs/promises");
module.exports = { load };

That comparison is correct, but incomplete. In a real Node.js project, the difficult part is not remembering import versus require. It is knowing which module system Node will apply to each file, how packages expose compatible entry points, and where interoperability stops being transparent.

How Node Decides a File’s Module Format

The clearest signals are file extensions:

1
2
.mjs  → ES module
.cjs  → CommonJS module

For .js files, the nearest parent package.json provides the usual boundary:

1
2
3
{
  "type": "module"
}

With this setting, .js files in that package scope are treated as ESM. Using "type": "commonjs" makes them CommonJS instead.

Modern Node.js can also detect ESM syntax in ambiguous files, but relying on detection makes a project harder for people and tools to understand. An explicit type field is a better contract.

This means the module format is not determined by whether the source file happens to contain TypeScript. It is the result of the source format, compiler configuration, output extension, package boundary, and runtime working together.

Static Structure Is an ESM Advantage

Ordinary ESM imports and exports have a statically analyzable structure:

1
import { calculatePrice } from "./pricing.js";

A tool can inspect that dependency without executing the module. This supports features such as tree shaking and more precise dependency analysis.

Traditional CommonJS loading is runtime code:

1
2
3
if (process.env.ENABLE_EXPERIMENT) {
  const experiment = require("./experiment.cjs");
}

That flexibility is useful, but it makes complete static analysis harder. This is one reason modern libraries often publish ESM, though it does not mean every existing application should immediately migrate.

The Runtime Differences You Actually Notice

ES modules do not provide the traditional CommonJS wrapper variables:

1
2
3
4
5
require
exports
module.exports
__filename
__dirname

Modern Node.js provides ESM-oriented alternatives for several common cases, including import.meta.filename, import.meta.dirname, and import.meta.resolve().

Relative ESM imports also follow URL-like resolution rules. When compiled JavaScript is executed directly by Node, imports normally need the emitted file extension:

1
2
// math.ts importing source that will become math.js
import { add } from "./add.js";

TypeScript’s NodeNext mode understands that ./add.js in the source can refer to add.ts during development. A bundler may offer different resolution conveniences, which is why copying a frontend tsconfig into a Node.js service can produce surprising results.

Interoperability Exists, but It Is Conditional

An ES module can import a CommonJS package. Node exposes the CommonJS module.exports value as the default export, and it may detect some named exports as a convenience:

1
import legacyPackage from "legacy-package";

CommonJS can always use dynamic import() to load ESM asynchronously:

1
2
3
4
async function loadModernPackage() {
  const module = await import("modern-package");
  return module.default;
}

Recent Node.js versions can also use require() for some ESM modules. The important condition is that the entire loaded ESM graph must be synchronous. If it contains top-level await, synchronous require() cannot load it and Node reports ERR_REQUIRE_ASYNC_MODULE.

So an ESM-only package is not automatically unusable from every CommonJS application, but neither is compatibility guaranteed merely because Node is recent. Check the supported Node version, the package’s exports, and whether asynchronous ESM features are involved.

Package exports Define the Public Surface

A file existing inside node_modules does not make it a public API. A package can use the exports field to declare exactly which entry points consumers may access:

1
2
3
4
5
6
{
  "exports": {
    ".": "./dist/index.js",
    "./testing": "./dist/testing.js"
  }
}

In this example, the package root and package/testing are public. An import such as this is not promised to work:

1
import type { InternalType } from "package/dist/internal/types";

TypeScript’s NodeNext resolution follows package exports by default. It may therefore reject an internal path that an older or looser setup happened to resolve. That strictness is useful: it exposes a dependency on package internals before a package upgrade breaks production.

Choosing a Module System for a Project

ESM is the JavaScript standard and aligns naturally with current browser and tooling ecosystems. CommonJS still has a large installed base and may be the lower-risk choice for an established Node.js application with older tooling.

Choose based on the whole execution path:

  • Which Node.js versions run the application?
  • Does the framework’s default build produce ESM or CommonJS?
  • Do the test runner, CLI scripts, and instrumentation support the choice?
  • Are important dependencies ESM-only or CommonJS-only?
  • Is the compiled output run directly by Node or processed by a bundler?

Do not switch an entire project because one dependency says ESM-only. First test that dependency in a minimal spike using the same compiler, test runner, and Node version as the real application.

The Practical Rule

Pick one primary module format, declare it explicitly, and treat package entry points as contracts.

The syntax is the easy part. Most ESM/CommonJS failures happen at boundaries: between package scopes, compiler output and runtime expectations, synchronous and asynchronous module graphs, or public exports and internal files. Make those boundaries explicit and the module system becomes much less mysterious.

This post is licensed under CC BY 4.0 by the author.