A module has `export let ready = false;` and sets `ready = true` during startup. Consumers using `import { ready }` see it become true, but after the same code is compiled to CommonJS and consumers write `const { ready } = require('./m')`, they see false forever. What explains the difference, and how would you export this value so it works under both?
answer
- one binds names, one returns an object
- destructuring copies right then
- read the property late, not early
- who performs the read, and when
- export the reader, not the value
basics
~20 sAn ES module import is a live binding to the exporter's variable, while destructuring a CommonJS require copies the property's value at that instant. Export a function or a state object so consumers read the value at access time instead of capturing it.
solid answer
~40 sThe two module systems bind differently. `import { ready }` creates an indirect binding to the `ready` variable inside the exporting module, so every read dereferences that variable and picks up the later reassignment. `require('./m')` returns the exports object, and `const { ready } = ...` immediately copies the current property value into a local `const` — after that, nothing connects the local to the module, so the flip to `true` is invisible. Keeping the object (`const m = require('./m')`) and reading `m.ready` at the point of use restores freshness, because the property read happens later. The portable fix is to stop exporting a bare mutable value: export `isReady()` returning the current state, or a single `state` object read as `state.ready`. Both make the read explicit and behave identically in either system.
go deeper
Know that destructuring a CommonJS require copies the value right then, while an ES module import reads the exporter's variable each time. Being able to state that difference is enough at this level.
Explain both mechanisms precisely — indirect binding versus a property read on the exports object — and show that reading the property late through the module object restores freshness.
Diagnose it as a shape problem: name the destructuring site as the capture point, explain why the failure looks intermittent and load-order dependent, and propose an accessor function or state container as the portable fix.
Own the convention: mutable exported values create invisible, module-system-dependent coupling. Decide whether readiness should be a polled flag at all, or a promise or event, and codify that so the boundary behaves the same regardless of how the code is built.
## The two mechanisms **ES modules bind by name.** During linking, `import { ready } from './m.js'` creates an indirect binding to the `ready` variable inside `m.js`. There is no copy; each read reaches into the exporting module's slot at the moment of the read. When startup runs `ready = true` inside `m.js`, every importer's next read sees `true`. **CommonJS hands back an object.** `require('./m')` evaluates the module (once) and returns whatever its exports object holds. That object is a normal object, and property reads on it are normal property reads. So freshness depends entirely on *when* you read the property: ```js // stale: the value is copied into a const at require time const { ready } = require('./m'); setTimeout(() => console.log(ready), 1000); // still false // fresh: the property read happens at use time const m = require('./m'); setTimeout(() => console.log(m.ready), 1000); // true, if m updated exports.ready ``` Destructuring is not special — it is exactly `const ready = requiredObject.ready`, a value copy. Once copied, the local `const` has no relationship to the module at all. ## Why the compiled version differs from hand-written CommonJS When a compiler lowers ES modules to CommonJS it goes out of its way to preserve liveness: an exported `let` reassignment is rewritten so the write also lands on the exports object, and each *use* of an imported name is rewritten as a property access on the required namespace rather than a captured local. That is why converted code usually keeps working. The bug in the question appears when a human writes the CommonJS by hand — or when a consumer written for CommonJS destructures a module that was itself compiled from ES modules. Destructuring at the boundary is where liveness is lost. This matters when diagnosing: the symptom "works in one build, stale in another" almost always traces to a single destructuring site, not to the exporting module. ## Why this specific bug is nasty - The stale value is usually a **boolean that only ever flips one way** (`ready`, `initialized`, `connected`), so the failure mode is "the feature is permanently off", with no error anywhere. - The timing is load-order dependent: a consumer required before startup finishes captures `false`; one required afterwards captures `true`. The same code then behaves differently depending on import order, which makes it look intermittent. - Nothing at the call site hints that the value is time-dependent. Reading `ready` looks like reading a constant. ## The portable export shapes **Export a function.** ```js // m.js let ready = false; export function isReady() { return ready; } export function markReady() { ready = true; } ``` The consumer writes `isReady()`. A function reference survives destructuring in either module system, and the read demonstrably happens at call time. This is the shape to reach for by default. **Export a state container.** ```js export const state = { ready: false }; ``` Consumers read `state.ready`. Copying the *object* reference is harmless because the property read is still deferred. The cost is unguarded shared mutable state — any consumer can write to it. **Signal completion instead of polling.** If consumers care about the transition rather than the current value, export a promise (`export const ready = init()`) or accept a callback. A boolean flag that everyone polls is often the wrong model to begin with; the flag exists because someone needed to *wait*. ## What to say in an interview Name the mechanism first — live binding versus value copy at destructuring time — then note that the exports object itself stays fresh if you read the property late, and finish with the design fix: do not export a bare mutable value across a module boundary. The liveness of ES module imports is a real guarantee, but it is invisible in the reading code and it does not survive a boundary crossing, so relying on it makes the code fragile in exactly the way this question demonstrates.
- Why does keeping `const m = require('./m')` and reading `m.ready` behave more like an ES module import?Because the property read is deferred to the point of use. `m` is a stable reference to the exports object, and the exporter updating `exports.ready` mutates that same object, so a later read sees the new value. It is not a true live binding — a full reassignment of `module.exports` would be missed — but for the common case it removes the staleness.
- Would exporting the flag as `export const ready = false` and reassigning it fix anything?No — `const` cannot be reassigned, so the module could never flip it, and consumers would be pinned to `false` in both systems. The problem is not the declaration keyword; it is exporting a bare value whose freshness depends on when the consumer reads it. Export a function or a container instead.
- How would you spot this bug from the symptom alone?The tell is a boolean that is permanently stuck at its initial value while the owning module clearly sets it, with behaviour that changes depending on load order. Grep for destructured requires of that module: the capture site is where the value froze. Reading the property through the module object at that one call site usually confirms the diagnosis immediately.
saying these in an interview costs you the question
- Blames module caching rather than the value copy
- Thinks destructuring a require keeps a live reference
- Claims ES module liveness survives into any consumer
- Says switching the declaration to const or var fixes it
- Assumes the exporting module failed to run at all