skip to content

A module `counter.mjs` contains `export let count = 0;` and `export function increment() { count++; }`. Another ES module runs `import { count, increment } from './counter.mjs'; increment(); console.log(count);` — what is logged, and why?

level: middleimportance: must knowfreq 68%

answer

  1. names, not values, are shared
  2. linking happens before evaluation
  3. the exporter keeps the variable
  4. every read dereferences the slot
  5. only the exporting module may write

basics

~20 s

It logs 1. An ES module import is a live, read-only binding to the exporter's own variable rather than a copy of its value, so reading count after increment() has run sees the exporter's updated value.

solid answer

~40 s

It logs `1`. When the module graph is linked, `import { count }` does not copy the current value of `count` into the importing module — it creates an immutable indirect binding that points at the `count` variable living inside `counter.mjs`. Every read of `count` in the importer dereferences that variable at the moment of the read, so once `increment()` has executed `count++` in its own module, the next read in the importer yields `1`. The liveness is one-directional: the importer may read the updated value but may not assign to `count` itself, because the import binding is read-only. This is the behaviour that distinguishes ES modules from a CommonJS `require`, where destructuring hands you whatever value the property held at that instant.

go deeper

for a junior

Know that an imported name reads the exporter's current value, so a counter incremented by an exported function shows the new number. Answer the puzzle with 1 and say the import is a binding, not a copy.

for a middle

Explain the mechanism: linking creates an indirect binding to the exporter's variable slot before any code runs, so each read dereferences that slot. Be ready to say the binding is immutable on the importing side.

for a senior

Show you know where this matters in production code: values flipped during startup, the silent behaviour change when a module is converted between module systems, and why an explicit accessor beats relying on a reassigned exported let.

for a principal

Frame it as an API-design choice. Mutable exported state is invisible coupling with no notification channel; argue for exported functions or an explicit state container, and for a codebase-wide convention so liveness never becomes load-bearing by accident.

## What the code logs It logs `1`, not `0`. The importing module reads the exporter's variable at the moment of the read, and by then `increment()` has already incremented it. ## A binding is not a value A *binding* is the association between a name and a storage slot. When you write `let count = 0`, the module creates a slot and points the name `count` at it. `count++` writes a new value into that same slot; it does not create a new slot. What `import { count } from './counter.mjs'` gives the importing module is a second name pointing at *that same slot*. The spec calls it an indirect binding, created during linking by the abstract operation `CreateImportBinding` after `ResolveExport` has located which module and which local name the export refers to. No value is transferred at import time — there is nothing to transfer, because linking happens before any module body runs. ```js // counter.mjs export let count = 0; export function increment() { count++; } ``` ```js // main.mjs import { count, increment } from './counter.mjs'; console.log(count); // 0 increment(); console.log(count); // 1 ``` The `increment` function is ordinary: it closes over its own module's scope, so `count++` inside it updates `counter.mjs`'s slot. The importer sees the change for free because it was never looking at anything else. ## The liveness is one-directional Import bindings are immutable on the importing side. Adding `count = 5;` to `main.mjs` does not work: the engine rejects the assignment (some engines report it while compiling the module, others when the statement runs, but it never succeeds and never reaches the exporter). Module code is always strict, so a rejected write raises an error rather than failing silently. So the rule is: **the exporting module is the single writer; every importer is a reader.** If consumers need to change the state, the exporter must export a function that performs the write — which is exactly what `increment()` is. ## What read-only does *not* cover Read-only applies to the binding, not to the object the binding points at. If a module does `export const settings = {}`, an importer cannot write `settings = {...}` but can freely write `settings.debug = true`, and every other module holding that object sees the new property. That is plain shared-object mutation, available in any module system; it is not the live-binding mechanism and does not require ES modules. ## Where the wrong intuition comes from Most people first meet modules through a system where the import really is a snapshot. Destructuring a CommonJS `require` copies the property's value into a local variable at that instant, so a boolean the exporter flips later is never observed. Carrying that mental model into ES modules produces the confident wrong answer `0` — and, more painfully, produces code that silently changes behaviour when it is converted between module systems. Bundlers do not change the semantics either. A bundler that concatenates modules into one scope keeps a single declaration for `count` and rewrites references to point at it, precisely so the live-binding behaviour is preserved. ## Where liveness actually bites - **Flags flipped during startup.** `export let ready = false;` set to `true` after initialisation works under ES modules and quietly breaks if a consumer snapshots it. - **Counters and caches** exported as reassigned `let` bindings: readers get fresh values, which is convenient right until someone converts the module. - **Ordering.** If the importer reads the value during its own top-level evaluation, it may read the exporter's *initial* value simply because the write has not happened yet. Liveness means "read now", not "notify me later" — there is no reactivity, no subscription, no change event. ## Practical guidance Liveness is real but implicit, and it is invisible at the call site: nothing about `console.log(count)` tells a reader that the value may have moved. For state that genuinely changes, prefer an explicit accessor (`getCount()`) or a single exported state object read as `state.count`. Both make the "fetch the current value" step visible in the code, and both behave identically no matter which module system the file ends up compiled to.

  • Does the same liveness apply to an exported function declaration or a class?
    Yes — the binding is live regardless of what it holds. Function and class declarations are rarely reassigned, so you never notice, but if the exporting module does `myFn = otherFn` later, importers calling `myFn()` call the replacement. The mechanism is indifferent to the value's type; it is the variable slot that is shared.
  • If the importer reads the value during its own top-level code, can it still see the old value?
    Yes. A live binding means the read is deferred, not that you are notified of changes. If the exporter reassigns during an async startup step and the importer logs the value at top level, the log happens first and shows the initial value. Liveness fixes staleness only for reads that occur after the write.
  • Can the importer detect that the exported value changed?
    Not through the binding itself — there is no change hook or observation mechanism in the module system. You would need the exporter to publish an event, resolve a promise, or accept a callback. Importers can only re-read the name and compare, which is polling, not notification.

saying these in an interview costs you the question

  • Says the import copies the value at import time
  • Claims primitives are copied and only objects stay shared
  • Thinks reassigning the import in the consumer fixes staleness
  • Believes a bundler turns imports into value snapshots
  • Calls live bindings reactive, expecting change notifications

context