ES Modules
import/export, and the CommonJS interop mess you will absolutely hit in Node.
25 minDifficulty 2/5tooling · languageAI-writtenWritten by a model on 16 August 2026 and not yet read by a person. Checked automatically: schema, the pedagogical rules the hand-written material is held to, and every diagram parsed for real.
Before this
Why this exists
Before modules, every script shared one global scope — two files both declaring a variable named `config` silently clobbered each other, and load order mattered in ways nobody could track. `import` and `export` give each file its own private scope with an explicit, declared list of what it shares and what it needs, ending an entire category of bugs.
The mental model
Each module is its own sealed box. Nothing inside is visible outside unless explicitly exported; nothing from outside is visible inside unless explicitly imported. The module graph — which files import which — is static and analyzable before any code even runs, which is what lets bundlers strip out code nobody actually imports.
How it works
Named exports vs default exports are a real design choice
`export const add = (a, b) => a + b` is a named export — importers must use that exact name (or rename it explicitly with `as`). `export default function() {}` lets the importer choose any name they like on import. Named exports make refactoring safer because tooling can track every usage of the exact name across a codebase; default exports can't be tracked the same way, since each importer might call it something different.
Imports are live bindings, not copied values
`import { count } from './counter.js'` doesn't copy the current value of `count` at import time — it creates a live reference. If the module later reassigns its exported `count`, every importer sees the new value immediately, without re-importing. This is fundamentally different from CommonJS's `require`, which does return a snapshot object.
The module graph is resolved before execution, statically
`import` statements must appear at the top level, not inside an `if` or a function — this is what lets a bundler build the complete dependency graph and know exactly what's imported from where without running any code, enabling tree-shaking (dropping unused exports) and predictable load order.
Node's CommonJS interop is where the real pain lives
A CommonJS package's `module.exports = fn` becomes, when imported via ESM's `import fn from 'pkg'`, either the function directly or wrapped as `{ default: fn }`, depending on how the package was built and which tool is doing the interop — this exact ambiguity is the single most common source of "why is this undefined" bugs when mixing module systems in Node.
The mechanism
Before any code runs, the loader (a bundler, or Node's own resolver) parses every `import`/`export` statement to build a complete dependency graph — which files need which other files, and in what order they must be evaluated. Only after this graph is fully resolved does actual module execution begin, top-level code running once per module regardless of how many other modules import it.
flowchart LR A[a.js\nexport const x] -->|import x from ./a.js| B[b.js] B -->|export default function| C[c.js] C -->|static graph resolved first| D[Bundler / Node loader]
What people get wrong
- require() and import are basically the same thing with different syntax.
- require() is synchronous, dynamic (can be called conditionally anywhere), and returns a value snapshot; import is statically analyzed at the top level and creates live bindings — they're different module systems with genuinely different semantics, not just different syntax for the same idea. This false equivalence is exactly why mixing the two systems in one Node project produces confusing edge cases around default exports and timing.
- A module's code runs every time it's imported.
- A module's top-level code runs exactly once, the first time it's imported anywhere in the graph — every subsequent import of the same module reuses the already-evaluated result, including any module-level state. Assuming re-execution leads to surprises around shared state — like a counter or a singleton instance — which is actually shared across every file that imports it, by design.
- Default exports are always the better, simpler choice.
- Default exports lose the ability for tooling to track a consistent name across a codebase, since each importer can rename it freely — named exports are generally recommended for anything beyond a single, obvious primary export from a file. Teams that default to default exports everywhere often end up with the same conceptual export called five different things across the codebase, making refactoring and searching much harder.
When not to use it
- A file has exactly one clear, primary thing it exports, like a single React component matching the filename.
- A default export is reasonable here — the ambiguity risk is low because there's only one obvious thing to import.
- You need to conditionally load a module only when a feature flag is enabled.
- Dynamic `import()` (a function call, not the static statement), which returns a promise and can be called anywhere, including inside an `if` — unlike static `import`, which cannot be conditional.
Terms
- Named export
- — An export with a specific, declared name that importers must use (or explicitly rename) — trackable across a codebase by tooling.
- Default export
- — A module's single, unnamed export that importers can call anything they like on import.
- Live binding
- — An imported value that stays synchronized with the exporting module's current value, rather than being a one-time copy.
- Tree-shaking
- — A bundler optimization that removes exported code nobody actually imports, made possible by the static, analyzable structure of ES module imports/exports.
In an interview
Why can bundlers tree-shake unused code from ES modules but generally cannot from CommonJS?
- ES module imports/exports are static — analyzable without running code
- CommonJS require() can be called conditionally or dynamically, so the full set of dependencies can't be known without executing the code
- static analysis lets a bundler prove an export is never used and safely remove it
Can you recall it?
What does it mean that an ES module import is a 'live binding', and how does that differ from CommonJS's require()?