Under The Hood
backend nodejs Node.js 20+ (ESM, CJS interop)

ESM Static Graph Evaluation vs CommonJS Synchronous require Cache

Last updated
Prerequisites:
Node.js module basics
Understanding of module systems and caching
  • nodejs
  • esm
  • commonjs
  • modules
  • require
  • import
  • resolution

Read at your depth

The practical view

CommonJS (require) is synchronous and dynamic: require('./mod') executes the module on demand, in order, and caches the result in require.cache (subsequent requires return the cached module.exports). ESM (import) is static: imports are hoisted, the module graph is resolved before execution, and evaluation runs in dependency order (post-order). ESM supports top-level await, tree-shaking, and static analysis; CJS supports conditional requires and dynamic module paths. Node.js supports both (package.json "type": "module" makes .js files ESM). Interop: CJS can require ESM only in newer Node via dynamic import(); ESM imports CJS via default import of module.exports.

The same idea in other frameworks

react equivalent
N/A — Node.js module system concern.

Legacy vs modern

Conditional/dynamic loading: CJS vs ESM

CJS can require conditionally with dynamic paths; ESM imports are static and hoisted, requiring import() for dynamic cases.

before → after
CJS dynamic require
const mod = require(condition ? './a' : './b'); // ❌ OLD: dynamic, synchronous
ESM static imports + dynamic import()
import { helper } from './helper.js'; // ✅ NEW: static, hoisted
const mod = await import(`./plugins/${name}.js`); // ✅ NEW: async dynamic

Interview gotchas

Context

This tests the module-graph semantics — interviewers want the link-then-evaluate model vs the partial-exports cache and the live-binding behavior.

The mechanical answer

CJS resolves cycles via the require cache: when module A requires B and B requires A, B gets A's PARTIAL exports (the exports object as it exists at that moment, possibly incomplete); code that reads a not-yet-assigned export gets undefined at runtime. ESM handles cycles by separating linking from evaluation: the loader resolves and links the entire graph first, creating live bindings for every export BEFORE any module body runs. When evaluation reaches a cycle, a module that's already in-progress is skipped (not re-evaluated), and importers read the bindings, which hold the values once the exporting module finishes evaluating. Because bindings are live, an import of a cyclic dependency reads the final value (if accessed after the other module completes), avoiding the CJS undefined-export trap — though accessing a binding before its module evaluates still sees undefined.

Trap

The naive answer is 'ESM forbids cycles'. It handles them via the in-progress skip and live bindings. Another trap: assuming CJS cycles are broken — they work but with partial exports, which is the classic 'got undefined' bug. Candidates who describe the link/evaluate split show real module-system understanding.

Under The Hood — a multi-depth technical interview hub.

Third-party licenses Press ⌘ K to search.