Loading an ES module graph is usually described as three phases. Name them, say what each one does, and explain why `import { missing } from './m.js'` fails before a single line of either module has run.
answer
- three phases, one of them is not running code
- what modules exist, then what names mean
- imports matched to exports before any body runs
- a missing export is a load error, not a runtime one
basics
~20 sES modules load in three phases: construction (fetch, parse, and recursively resolve the graph), linking (create bindings and connect each import to a real export), and evaluation (run module bodies). A name that no module exports is caught during linking, so it is a load-time error with no code executed.
solid answer
~40 sFirst **construction**: starting from the entry module, the engine parses each source, reads its import specifiers straight out of the syntax, resolves and fetches those, and repeats until the graph is complete. Second **linking** (instantiation): every module gets its environment, exported bindings are created, and each import is resolved to the specific export it refers to. Third **evaluation**: bodies run, depth-first, once per module. Because the import-to-export match happens in the linking phase, `import { missing } from './m.js'` is rejected there — engines report a `SyntaxError` along the lines of "The requested module './m.js' does not provide an export named 'missing'" — and no top-level code from either module has executed. A resolution failure, like a specifier that maps to no file, fails even earlier, in construction.
go deeper
Know the order of the words — the graph is discovered and wired up before any module code runs. Being able to say that a bad import name is caught at load time, not when the line executes, is enough here.
Name all three phases and place at least three errors in them: unresolvable specifier, missing or ambiguous export, and a throw in a module body. Explain that linking connects names to exports without producing values.
Use the phases to explain failure behaviour you have operated: whole-graph load failures, a module that throws once and stays failed, and why import mismatches surface at deploy rather than in a rare code path.
Frame it as an integrity guarantee: static linking makes cross-module contract breaks fail loudly at load, which changes what your CI and release process must catch versus what the runtime will catch for you.
## The three phases ### 1. Construction (parse and load the graph) The engine takes the entry module, parses it, and reads its dependency list directly out of the syntax — the specifiers in its `import` declarations. Each is resolved to a concrete location (a URL in a browser, a file path in a server runtime), fetched, and parsed, and the process repeats for their dependencies until every module in the graph has been parsed into a module record. Nothing has executed yet; the engine only knows the shape of the graph and, for each module, which names it imports and which it exports. Errors that surface here: a specifier that resolves to nothing (a 404, or a "module not found" error), and a plain parse error in any file in the graph. ### 2. Linking (instantiation) Now each module gets its environment record, and the bindings for its declarations and exports are created — created, but not yet initialised. Then, for every import in every module, the engine resolves the imported name to a concrete export in some module, following re-exports as far as necessary. This is where the missing-export failure lives: ```js // m.js export const a = 1; // main.js import { missing } from './m.js'; // rejected during linking console.log('never printed'); ``` There is no export named `missing`, so resolution fails and the graph never reaches evaluation. Engines surface this as a `SyntaxError` — for example "The requested module './m.js' does not provide an export named 'missing'" — which reads oddly, because the file itself parses fine; it is the *graph* that is malformed. Ambiguity fails here too: if a module re-exports `*` from two modules that both export `x`, importing `x` is an ambiguous resolution and is an error, even though each file on its own is valid. The crucial property is that this is a whole-graph check that happens **before** any of your code runs. You cannot ship a build where module A imports a name module B stopped exporting and only find out when that code path executes at 3am — the failure is immediate and total. ### 3. Evaluation Only now do module bodies run, depth-first: each module's dependencies are evaluated (in the order of its import declarations) before its own body, and each module is evaluated exactly once no matter how many modules import it. An exception thrown by a module body during evaluation is remembered — the module is permanently marked as failed, and later attempts to load it produce the same error rather than re-running the body. ## Why the split exists Separating linking from evaluation buys several things at once. **Errors move earlier.** Import/export mismatches become load-time errors rather than "undefined is not a function" at some unpredictable moment. **Bindings can be live.** Because the connection between an import and an export is established as a link between variables in the linking phase — not as a value copy at execution time — an importer sees later updates to the exporter's variable. **Cycles are tractable.** All bindings across the graph exist before any body runs, so a cycle can be evaluated at all; the only remaining question is whether a binding has been *initialised* by the time it is read. **The graph is knowable without running code.** Tooling can compute the full dependency graph from syntax alone, which is what makes ahead-of-time analysis of a codebase possible. ## How to talk about it A useful summary: "construction answers *what modules are there*, linking answers *which variable does each name refer to*, evaluation answers *what are the values*." Then place the errors: unresolvable specifier → construction; missing or ambiguous export → linking; a `throw` in a module body → evaluation. A good follow-up to volunteer is the contrast with the older, dynamic style of module loading, where a module is fetched and executed by a function call at runtime: there, a mis-typed import name is simply an `undefined` property, discovered only when something touches it. The static phase split is what turns that class of bug into an immediate, whole-graph failure.
- Where does a specifier that resolves to no file fail — and how is that different from a missing export?It fails during construction, while the engine is fetching and parsing the graph, because the module cannot be obtained at all; the runtime reports a resolution or network error. A missing export fails one phase later, in linking: the file was fetched and parsed fine, but the name cannot be matched to an export. Both happen before any module body runs.
- Why does splitting linking from evaluation matter for circular imports?Because all bindings across the whole graph are created during linking, before any body runs, a cycle can be evaluated at all — each module can refer to names from the other. What is left is only the question of whether a binding has been initialised yet at the moment it is read, which depends on where evaluation starts.
- What happens if a module body throws while it is being evaluated, and something imports it again later?The module is recorded as having failed with that error. It is not re-evaluated on a later import; the same error is produced again. Module bodies run at most once per module instance, so a failed initialisation is sticky rather than retried, which is one reason to keep risky work out of top-level code.
saying these in an interview costs you the question
- Says a wrong import name is just undefined at runtime
- Thinks modules are fetched and executed one at a time
- Claims linking happens after the first module body runs
- Believes a re-import retries a module that threw
- Confuses a parse error in one file with a graph-level link error