Moduleshigh

ES Modules (ESM)

ESM is the official JavaScript module standard (import/export). Node.js supports ESM in .mjs files or .js files when package.json has "type": "module". ESM imports are static and analyzed at parse time.

Memory anchor

ESM = a shared Google Doc with live cursors. Everyone sees changes in real time (live bindings). CJS = emailing a PDF copy—you get whatever was written at send time and never see updates. ESM also scans the whole doc before opening (static analysis).

Expected depth

Unlike CJS, ESM imports are live bindings—if a module updates an exported value, importers see the update. ESM loading is asynchronous: the loader fetches, parses, and links modules before executing them (using a graph traversal). Top-level await is supported in ESM, enabling async initialization at the module level. To import a CJS module from ESM, use a default import; to import ESM from CJS, you must use dynamic import() since CJS require() is synchronous and can't await ESM loading.

Deep — senior internals

ESM uses a three-phase loading process: (1) Construction—parse source, build module graph, find all imports; (2) Instantiation—allocate memory for exports, create live binding environment records; (3) Evaluation—execute module code. This means all imports are resolved before any module code runs, enabling static analysis for tree-shaking. Circular ESM dependencies work differently from CJS: because exports are live bindings (not copied values), circular imports don't return partially-constructed objects—they return the binding, which will be populated once evaluation completes. However, using an exported value before the providing module's code has run throws a ReferenceError (temporal dead zone for let/const exports).

🎤Interview-ready answer

ESM uses static import/export analyzed at parse time, enabling tree-shaking and top-level await. Imports are live bindings to the exporting module's values. Loading is asynchronous (three phases: construct, instantiate, evaluate). Mixing CJS and ESM requires care: CJS can't require() ESM (no sync await), but ESM can import CJS via default import. Use dynamic import() to load ESM from CJS.

Common trap

ESM live bindings mean `import { count } from './counter.js'` gives you a live read-only view of counter.js's count variable. If counter.js increments count, your imported binding reflects the new value. This is fundamentally different from CJS where you get a copy of the value at require() time—and it breaks destructuring-based memoization patterns.

Related concepts