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
N/A — Node.js module system concern.N/A — Node.js concern.N/A — Node.js concern.N/A — Node.js 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.
const mod = require(condition ? './a' : './b'); // ❌ OLD: dynamic, synchronousimport { helper } from './helper.js'; // ✅ NEW: static, hoisted
const mod = await import(`./plugins/${name}.js`); // ✅ NEW: async dynamicInterview 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.
Context
This probes the interop layer — interviewers want the default-export wrapping, the synthetic named exports, and the cases where interop silently surprises.
The mechanical answer
When ESM imports a CJS module, Node loads the CJS module and exposes its module.exports as the DEFAULT export (import cjsMod from './cjs.js'). Named exports are ALSO synthesized when possible: Node uses cjs-module-lexer to statically analyze the CJS source and detect assignments like exports.foo = ... or module.exports = { foo, bar }, generating matching named exports. Limitations: dynamic exports (exports[key] = value) or module.exports reassignment with computed keys can't be statically detected, so named imports of those fail (only default works). Conversely, CJS can require ESM only through dynamic import() (require is synchronous and cannot await ESM evaluation); in older Node versions this throws ERR_REQUIRE_ESM. The mixed cases are where interop bugs surface.
Trap
The naive answer is 'ESM can import CJS named exports freely'. Named exports work only when cjs-module-lexer can detect them statically; dynamic/assigned exports need the default import. Another trap: requiring ESM from CJS synchronously — it's impossible; you must use dynamic import(). Candidates who describe the lexer analysis and the sync/async boundary show real interop knowledge.