Articles
JavaScript Module Interview Questions: Live Bindings, Cycles, and ESM Interop
Practice JavaScript module interviews with live-binding traces, early cycle failures, default-export traps, and version-aware CommonJS interoperability.
A module imports a counter, increments it through an exported function, and still prints an old value somewhere else. Another pair of modules imports each other and fails before the application's entry point runs. Both problems become easier when you stop treating an import as a copied object and start tracing binding creation, initialization, and reads.
Official language behavior: For JavaScript module interview questions, the useful distinction is this: an ECMAScript module import observes an exported binding; an ordinary assignment captures the value read at that moment. Cycles are not automatically invalid, but an early read can reach an uninitialized binding. CommonJS interoperability adds a separate contract, so name the runtime and file format before predicting output. MDN import behavior and module cycles.
These are original interview exercises grounded in official documentation, not reported employer prompts. No candidate reports establish question frequency or an employer's assessment format; preparation recommendations are inference.
What does a live binding actually mean?
A live binding is a connection to a variable exported by another module. Reading the imported name retrieves that binding's current value. It does not give the importer permission to reassign the export, and it does not make an ordinary local variable update itself after initialization.
MDN's import reference describes imports as read-only live bindings. Start with this original two-file fixture:
// counter.mjs
export let count = 0;
export function increment() { count += 1; }
// main.mjs
import { count, increment } from './counter.mjs';
const copied = count;
increment();
console.log(count, copied); // 1 0
The first read initializes copied to zero. The function later changes the exporting module's count. The next imported read sees one, while copied still holds zero. A useful follow-up is to replace copied with a function returning count: the function reads later rather than preserving the earlier number.
Do not explain this as “imports are references” without specifying the target. A reference to an object, a binding to a variable, and a local snapshot are different relationships. Drawing those three arrows is often more convincing than repeating terminology.
Can an importer mutate an exported object?
Consider export const settings = { theme: 'light' }. An importer cannot assign a new value to the imported settings name. It can nevertheless write settings.theme = 'dark' unless the object is protected by some additional mechanism. The binding's reassignment rule does not freeze the referenced object's properties.
In the original counter fixture, add a second exported object and have two consumers read its theme. Both can observe mutation of that same object. Then ask whether a consumer that ran const theme = settings.theme will update. It will not: that local string was read once.
For a design answer, choose ownership explicitly. A configuration API might expose read-only snapshots and a controlled update function instead of allowing arbitrary consumers to mutate shared state. That is an architectural recommendation, not something ESM enforces automatically. Explain how callers subscribe to updates if the UI needs them; a live import alone is not a rendering notification system.
Why do two default-export forms behave differently?
The export reference distinguishes exporting a value expression from exporting an existing binding under another name. That distinction produces a compact interview trap:
// defaults.mjs
let value = 0;
export { value as live };
export default value;
export function change() { value = 7; }
// reader.mjs
import initial, { live, change } from './defaults.mjs';
change();
console.log(initial, live); // 0 7
Here the expression used by export default value supplies its evaluated value. The named export still refers to the original variable. Contrast this with export { value as default }, which exports that variable's binding under the default name.
Keep the fixture small enough to run in a fresh process. Combining it with a bundler, hot reload, and a framework obscures which system produced the result. Once the language-level explanation is correct, add those layers one at a time.
Why can an ESM cycle throw before main runs?
MDN's modules guide explains cyclic imports and the risk of reading a binding before initialization. The critical question is not merely whether a cycle exists. It is when evaluation reaches a read relative to the declaration that initializes the export.
// a.mjs — run this file
import { b } from './b.mjs';
export const a = 1;
console.log(b);
// b.mjs
import { a } from './a.mjs';
export const b = a + 1;
For this entry point, evaluating the dependency reaches a before a.mjs initializes its const. The read throws a ReferenceError; it does not quietly substitute undefined. Trace the read site, not just the visual order of files in an editor.
Now change b.mjs to export function readA() { return a; }, and call that function from a.mjs after initializing a. The delayed read can succeed. Merely replacing a variable with a function is not a universal repair, however: calling that function too early recreates the problem.
An interviewer may ask whether changing const to var “fixes” the cycle. It can change the observed early value and hide the exception while leaving initialization logic wrong. The engineering answer is to remove the premature dependency or introduce an explicit startup phase, not to choose a declaration solely because it fails less loudly.
How would you repair the dependency graph?
Suppose registry.mjs imports service implementations to register them, while each service imports the registry to announce itself at module evaluation time. Moving one read into a timer might make a demonstration pass, but it leaves initialization ordering implicit.
One original repair is to place shared identifiers in a dependency-free module. A second is to let the application entry point construct services and then pass them into a registration function. A third is to inject the registry into a service factory instead of importing the singleton from inside the implementation.
Choose according to the actual dependency. Shared constants belong below both modules. Runtime collaboration belongs in construction or an explicit initialization method. Plugins may justify dynamic loading, but the loader should still describe when a plugin becomes ready and how failures are reported.
After a repair, test that importing the library alone does not start a server, connect to production, or depend on a hidden environment variable. Module evaluation is a poor place for an irreversible side effect because even a consumer inspecting exports can trigger it. This is a testability recommendation derived from the fixture, not a ban on all module-level initialization.
Is CommonJS destructuring also a live import?
No. CommonJS require() returns a module's exported value, and ordinary JavaScript destructuring then reads properties from that value. The Node.js CommonJS documentation describes export objects, caching, and incomplete exports during cycles.
// counter.cjs
exports.count = 0;
exports.increment = () => { exports.count += 1; };
// reader.cjs
const counter = require('./counter.cjs');
const { count } = counter;
counter.increment();
console.log(counter.count, count); // 1 0
Keeping the returned object lets you read its later property value. Destructuring a numeric property captures the earlier number. That result resembles the ESM snapshot exercise, but the mechanisms are different.
Now let a CommonJS module replace module.exports later. A consumer already holding the previous object does not have its local reference rewritten. Distinguish “the old object was mutated” from “the module selected a new exported object.” A migration adapter should avoid promising live updates merely because an imported value looks like a namespace.
What changes when ESM imports CommonJS?
Node's ESM interoperability documentation explains its CommonJS namespace wrapper. The default import represents the CommonJS exported value; named exports may be detected from source patterns. Detected named exports are not equivalent to arbitrary live property subscriptions.
An original probe can export count, import both the default and a named count, then mutate the CommonJS object's property. Ask which expression reads the object now and which was exposed through the interoperability wrapper. If a package's named import fails, inspect its actual export shape instead of assuming TypeScript's editor completion describes runtime behavior.
For require() loading ESM, current Node documentation limits support to synchronous ESM graphs without top-level await. Older runtimes differ. A strong answer gives the Node version, the package format, and whether any dependency introduces asynchronous module evaluation. Do not repeat the outdated universal rule that CommonJS can never load ESM, or the opposite rule that every ESM package can be required.
For published packages, inspect their declared entry points rather than importing an undocumented internal file. A test that passes against one installed layout does not establish a supported consumer API.
How do you test module questions reliably?
Use explicit .mjs and .cjs fixtures, relative imports with file extensions, and one fresh process per case. Record the runtime version next to the output. Avoid reusing a process whose module cache already contains the fixture: import-time side effects may not run again under the same resolved identity.
The test matrix should contain a live-binding update, a copied value, the two default-export forms, a failing early cycle, a successful delayed cycle, and CommonJS property mutation. Keep the expected exception type separate from the successful output string. An error is evidence of a specific read boundary, not proof that all cycles fail.
Add a test where two consumers import the same counter and verify shared state. Then introduce a deliberately different module identity only if you are investigating duplicate instances. A package bundled twice, separate execution environments, or different loader identities can undermine a singleton assumption without changing binding semantics.
Our original fixtures were rerun in Node.js v26.9.0: live import versus copy printed 1 0, default expression versus named binding printed 0 7, and CommonJS object versus destructured property printed 1 0. The failing cycle produced the expected ReferenceError; the delayed-read cycle printed 1. A separate default-binding alias test observed the later value 7. Current unversioned Node documentation was v26.10.0 when checked, so distinguish the documentation version from the executed runtime. These checks do not establish browser, bundler or hot-reload behavior.
For top-level await, distinguish a module import that has not settled from an exported value that is already available. State what the application should do on initialization failure rather than leaving callers to discover a hanging startup path.
Practice the binding and initialization contract
| Practice question | Topic-specific follow-up |
|---|---|
| Predict JavaScript let and var Behavior | Trace binding initialization before reading an import. |
| Run Promise Tasks with Bounded Concurrency | Separate async readiness from module loading. |
| Design a REST API Abstraction Layer | Keep module initialization independent of service startup. |
| Implement retry wrapper and interdependent validators | Expose dependencies rather than hiding them in imports. |
| Review and Improve Four AI-Assisted TypeScript Pull Requests | Check emitted imports and package boundaries. |
For each exercise, identify one value copied too early or one dependency read during startup. Explain the resulting failure before proposing a factory, callback, or adapter. That keeps the practice tied to this article's module boundary.
A strong final answer names the read boundary
For JavaScript module interview questions, finish with a trace: which module owns the binding, when it initializes, when the consumer reads it, and whether an interop layer changes the contract. Then choose a repair and demonstrate it in a fresh runtime fixture.
Aim to explain why the result occurs, not to memorize the printed numbers.
Sources and Further Reading
- MDN: JavaScript modules
- Node.js: ECMAScript modules
- Node.js: CommonJS modules
- MDN: export statement
- MDN: import statement
Research rechecked October 1, 2026. Node.js interoperability is version-sensitive; the fixtures are original exercises and do not establish bundler or browser behavior beyond the stated environment.