Tags: web-dev concept

Modules

Date: 2026-09-28


JavaScript has two module systems. ESM links a static graph before running anything and exports live bindings; CommonJS runs files on demand and exports a plain object. Almost every “why won’t this import” error is the seam between them.


A module is a file with its own scope that shares bindings only through explicit exports. JavaScript has two module systems. ESM (ECMAScript modules) is the language standard, using import/export. CommonJS (CJS) is Node’s original system, using require()/module.exports.

The two, side by side

ESMCommonJS
Syntaximport / exportrequire() / module.exports
When imports resolveBefore any code runs — parse the whole graph, link, then evaluateWhen require() executes, mid-file
What an import gives youA live binding to the exporter’s variableA copy of whatever module.exports held at that moment
Can be conditionalOnly via import(), which returns a promiseYes — require is just a function
Top-level awaitYesNo
Strict modeAlwaysOpt-in
Top-level thisundefinedmodule.exports
File pathsFull specifier, extension included, in Node and browsersExtension and index.js inferred
Current file locationimport.meta.url (import.meta.dirname in Node)__dirname, __filename

Static structure is ESM’s defining property. Because imports are declarations, not function calls, tools know the whole graph without running it — which is what makes tree-shaking possible — Module Bundling.

Live bindings — the load-bearing difference

// counter.mjs
export let count = 0;
export function increment() { count++; }
 
// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count);    // 1 — the import is a view of counter's variable
 
// the CommonJS equivalent
// counter.cjs:  let count = 0; module.exports = { count, increment };
// main.cjs:     const { count, increment } = require('./counter.cjs');
//               increment(); console.log(count);  // 0 — a copy taken at require time

Importers can’t assign to an import (count = 5 throws) — only the exporting module can change it.

Evaluated once, cached by resolved path

A module’s top level runs once per resolved file. Every importer gets the same instance, which is why a module-level let cache = new Map() behaves as an app-wide singleton.

The dual-package hazard follows directly. A package ships both an ESM and a CJS build. Your code imports it, a dependency requires it — two different files, so two instances. The consequences:

  • two singletons, each with half the state
  • instanceof failing across the boundary
  • two copies of the code in the bundle

Node’s seam

  • Which system a .js file uses is set by the nearest package.json’s "type": "module" means ESM; absent or "commonjs" means CJS. .mjs and .cjs force it per file
  • Packages map entry points with the exports field, choosing a file per condition (import, require, browser, types). A package with exports also hides every file it doesn’t list
  • ESM importing CJS works. The default import is module.exports. Named imports work only when Node can detect them statically, which is best-effort
  • CJS requiring ESM was impossible for years (await import() was the only route). require(esm) is now unflagged in Node v20.19, v22.12 and v23+, and marked stable from v25.4. It returns the module namespace object, with the default under .default. It throws ERR_REQUIRE_ASYNC_MODULE if the module graph uses top-level await

In the browser

  • <script type="module"> is deferred by default, fetched with CORS (cross-origin resource sharing), and runs once however often it’s included
  • Import maps let bare specifiers (import 'lodash') resolve without a bundler
  • Unbundled, a deep graph is a waterfall of requests discovered one level at a time. That’s why production still bundles, and why modulepreload exists — Resource Hints

Where it goes wrong

  • Circular imports. In ESM, a module that reads an import before the exporter has finished evaluating gets a TDZ (temporal dead zone) ReferenceError. In CJS it silently gets a half-filled exports object. Both depend on import order, so they appear and vanish as files are reshuffled — Scope and Hoisting
  • “Cannot use import statement outside a module” — ESM syntax in a file Node treats as CJS. Fix the "type" or the extension, not the code
  • Default-export interop. Transpiled code marks CJS output with __esModule, and tools disagree on whether import x from 'cjs-pkg' means module.exports or module.exports.default. The same import behaves differently under the bundler, Node and the test runner — Transpilation
  • Top-level side effects. Code that runs on import (registering globals, patching prototypes) defeats tree-shaking and makes import order matter. The sideEffects field in package.json tells bundlers which files are safe to drop