“A module exports let count = 0 and an increment() function. Another module imports both, calls increment(), then logs count. What prints, and would it be different with require?” This question separates people who use modules from people who understand them. ES modules and CommonJS look interchangeable in day-to-day code, but they bind imported names in fundamentally different ways, and the difference shows up in counters, feature flags, mocks in tests and circular dependencies.
Interviewers ask it because it touches several deeper ideas at once: bindings versus values, module evaluation order, the temporal dead zone, and why bundlers and test frameworks behave the way they do.
Before you start
You should know import/export syntax and require/module.exports, the difference between let and const, and what the temporal dead zone (TDZ) is: a let or const variable exists from the start of its scope but throws if read before its declaration runs. To run the examples, save each file with a .mjs extension (ESM) or .cjs (CommonJS) and use Node 18 or later.
The short answer
An ES module import is a live, read-only binding to the exporting module’s variable. When the exporter reassigns that variable, every importer sees the new value on its next read. CommonJS require returns the module.exports object; destructuring from it copies the current values, so later reassignments inside the module are invisible. Importers can never assign to an ES import (it throws a TypeError), so only the owning module controls the value.
How it works
When Node (or a browser) loads ES modules, it first parses every file and links the graph: each import { count } is wired directly to the count variable slot in the exporting module’s environment. No value is copied. Reading count in the importer is like reading through a window into the other module’s scope.
// counter.mjs
export let count = 0;
export function increment() {
count += 1;
}// main.mjs
import { count, increment } from './counter.mjs';
console.log(count); // 0
increment();
console.log(count); // 1
try {
count = 10;
} catch (e) {
console.log(e.constructor.name); // TypeError
}The CommonJS version behaves differently because require simply runs the file and hands back the module.exports object. Destructuring reads count once.
// counter.cjs
let count = 0;
module.exports = {
count,
increment() { count += 1; },
get live() { return count; },
};// main.cjs
const { count, increment } = require('./counter.cjs');
const counter = require('./counter.cjs');
increment();
console.log(count); // 0
console.log(counter.count); // 0
console.log(counter.live); // 1Even counter.count stays at 0: the object property was filled with a copy of the number when the module ran. Only a getter, or mutating a property on exports itself, gives CommonJS consumers fresh values.
Step-by-step walkthrough
Step 1: Separate bindings from objects
Live bindings are about reassignment of the exported variable. If the export is an object, both systems share the object reference, so mutations to the object are visible everywhere. The difference only appears when the variable itself is pointed at something new.
// config.mjs
export let settings = { theme: 'light' };
export function reset() {
settings = { theme: 'default' };
}// app.mjs
import { settings, reset } from './config.mjs';
settings.theme = 'dark';
console.log(settings.theme); // dark
reset();
console.log(settings.theme); // defaultMutating a property of an imported object is allowed (the binding is read-only, the object is not). After reset() reassigns the variable, the import follows it.
Step 2: Watch how namespace imports behave
import * as ns gives a module namespace object whose properties are live getters over the exports. It is frozen-like: you cannot add, delete or assign properties.
// ns.mjs
import * as counter from './counter.mjs';
counter.increment();
counter.increment();
console.log(counter.count); // 2
console.log(Object.isFrozen(counter)); // false
console.log(Object.isSealed(counter)); // trueThe namespace is sealed rather than frozen because its properties are writable from the exporter’s side, even though assignments from outside throw.
Step 3: Trace a circular import
Because imports are bindings, a circular dependency can work as long as nobody reads a binding before the exporting module has initialised it. Function declarations are hoisted and initialised at link time, so they are safe; let, const and class exports are in the TDZ until their line runs.
// a.mjs
import { b } from './b.mjs';
export function a() { return 'a'; }
console.log('a sees', b());// b.mjs
import { a } from './a.mjs';
export function b() { return `b calls ${a()}`; }Running node a.mjs prints a sees b calls a. Node evaluates b.mjs first (it is a dependency), but b only defines a function; by the time a.mjs calls b(), both functions exist. Change a in a.mjs to export const a = () => 'a'; and also call a() at the top level of b.mjs, and you get ReferenceError: Cannot access 'a' before initialization.
Step 4: Understand the consequences for tests
Because importers cannot reassign bindings, you cannot monkey-patch an ES import in a test (counter.increment = fakeFn throws). Test frameworks either intercept module loading (vi.mock, jest.unstable_mockModule, Node’s mock.module) or you design for injection by passing dependencies in. In CommonJS, tests often patch properties on the exports object, which works only if the code under test reads the property at call time rather than destructuring at the top.
Worked scenario
A Node service kept a feature flag in a module that reloaded it from a remote config every minute. Handlers in a CommonJS codebase destructured the flag at the top of their files, so a flag flipped in production never took effect until restart.
// flags.cjs
let checkoutV2 = false;
setTimeout(() => { checkoutV2 = true; }, 10);
module.exports = { checkoutV2 };// handler.cjs
const { checkoutV2 } = require('./flags.cjs');
setTimeout(() => console.log('flag:', checkoutV2), 30);
// flag: falseMigrating the flag module to ESM with a live export fixes it with no change in how handlers read the flag:
// flags.mjs
export let checkoutV2 = false;
setTimeout(() => { checkoutV2 = true; }, 10);// handler.mjs
import { checkoutV2 } from './flags.mjs';
setTimeout(() => console.log('flag:', checkoutV2), 30);
// flag: trueIf the codebase must stay on CommonJS, export a function (isEnabled()) or a getter, and read it at request time. That approach is clearer in either module system, since it makes the value’s changing nature explicit.
Common mistake
- “Imports are copies of values.” In ESM they are bindings. A reassignment in the exporter is visible to importers.
- “Imports are constants, so the value never changes.” Importers cannot assign, but the exporter can, which is the whole point.
- “
export defaultis live too.”export default expressionexports the value of that expression at evaluation time, through a hidden binding. Later reassigning the variable you passed does not update it.export { value as default }does stay live. - “CommonJS
exports.countupdates automatically.” It holds whatever was assigned; reassigning a local variable does not touch it. - Blaming circular imports on modules themselves. They usually fail because a top-level statement reads a
let,constor class binding before its module has run.
Verify the behavior
This self-contained script writes two small modules into a temp folder, imports them, and checks both the live binding and the default export snapshot.
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
const dir = mkdtempSync(join(tmpdir(), 'live-'));
writeFileSync(join(dir, 'm.mjs'), `
export let n = 1;
let d = 'first';
export default d;
export { d as liveDefault };
export function bump() { n++; d = 'second'; }
`);
const mod = await import(pathToFileURL(join(dir, 'm.mjs')));
mod.bump();
console.log(mod.n, mod.default, mod.liveDefault); // 2 first secondn and liveDefault follow the variables; default was captured when export default d ran.
Follow-up questions
Why did ES modules choose live bindings? They make static linking possible (imports are resolved before any code runs), support circular dependencies more gracefully, and let tools analyse the graph for tree shaking.
Can you require an ES module? Recent Node versions support require() of synchronous ES modules; older ones need dynamic import(), which returns a promise of the namespace object.
Does a dynamic import() give live bindings? Yes. The namespace object’s properties read the current values of the exports.
What happens to bindings when a bundler converts ESM to one file? Bundlers preserve semantics by referencing the same variable or using getters, so live bindings survive bundling.
Interview exercise
Predict the output of running node main.mjs, then explain how to make the third line print 3 using only changes inside store.mjs.
// store.mjs
let total = 0;
export default total;
export function add(n) { total += n; }
export const current = () => total;// main.mjs
import total, { add, current } from './store.mjs';
add(3);
console.log(current()); // 3
console.log(total); // 0Answer and reasoning
current() reads the module’s total variable directly, so it prints 3. The default import prints 0 because export default total evaluates the expression total once, while the module runs, and exports that value through an internal binding that nothing reassigns. The total name in main.mjs is a live binding, but to that hidden default slot, not to the let total variable.
To make the default track the variable, replace export default total; with export { total as default };. That form exports the variable binding itself under the name default, so after add(3) the import reads 3. A good answer also suggests the getter approach (current()) as the more explicit API, since readers do not expect a default import to change.
Continue learning
Work through more JavaScript interview questions and the JavaScript MCQs. Related notes: fixing “Cannot use import statement outside a module” and ReferenceError: is not defined. Official reference: MDN on import and live bindings.