skip to content

In JavaScript, if module a.js imports b.js and b.js imports a.js back, does the module system reject the graph or refuse to load it? Explain what actually happens.

level: juniorimportance: should knowfreq 40%

answer

  1. graph terminates, no infinite loop
  2. registered before the body runs
  3. require.cache and the ESM module map
  4. failure is timing, not loading

basics

~20 s

JavaScript allows circular imports. Both ES modules and CommonJS register a module before running its body, so the cycle terminates instead of looping. Loading never errors by itself — trouble comes when one module reads a value the other has not produced yet.

solid answer

~40 s

Cycles are legal in both module systems, and neither loader recurses forever, because a module is registered before its body executes. CommonJS puts the module object into `require.cache` keyed by resolved filename and only then runs the file, so the back-edge `require` returns the entry that already exists. ES modules keep a module map keyed by resolved URL and link to the existing record. Either way every module body runs exactly once. The consequence is that the failure is never "the graph would not load" — it is that the partner observed the module mid-evaluation: CommonJS hands back a partially filled `exports` object with silent `undefined` properties, while ESM hands back a binding that exists but may still be uninitialized, which throws on read.

go deeper

for a junior

Be able to say plainly that circular imports load rather than error, and that each module body runs once because the loader registers the module before executing it.

for a middle

Name the mechanism concretely — require.cache in CommonJS, the module map in ESM — and give the two failure shapes: a partially filled exports object versus a binding that exists but is uninitialized.

for a senior

Show that you treat a cycle as an evaluation-order hazard: the same graph can pass a unit test and break in the bundled app because a different entry point decides which module runs first.

for a principal

Own the policy question — whether cycles are blocked in CI at all, and which structural habits (re-export barrels, grab-bag utility modules, mutually referencing domain objects) keep manufacturing them.

## What a circular import is A cycle exists when the import graph contains a path that returns to where it started: `a.js` imports `b.js` and `b.js` imports `a.js`, or the loop runs through five files and is much harder to see. Nothing in ECMAScript and nothing in CommonJS forbids this. Both loaders are designed to survive it, and both survive it the same way — **a module is registered before its body executes**. ## Why the loader does not recurse forever CommonJS keeps `require.cache`, a map from resolved absolute filename to the module object. A call to `require(specifier)` resolves the specifier, checks the cache, and on a hit returns the cached `module.exports`. The ordering is the critical part: Node creates the module object, inserts it into the cache, and only afterwards compiles and runs the file. So when `a.js`'s body reaches `require('./b.js')` and `b.js` immediately requires `a.js` back, the entry for `a.js` is already present. The loader returns it rather than starting a second execution. ES modules do the same thing one level up. The host keeps a module map keyed by resolved URL, and each URL produces exactly one Module Record. When the loader walks the graph and meets a specifier already in the map, it links to the existing record instead of fetching and parsing again. Every module body still evaluates exactly once. ## The load succeeds; the read is what fails Because the cycle terminates by handing back a record that exists but may not be finished, the interesting question is never "does it load" — it is "what is inside that record at the moment the partner looks". - In **CommonJS**, `require` returns the partially populated `exports` object. Whatever was assigned before the back-edge is there; whatever is assigned after is missing. You get `undefined`, with no warning at all. - In **ES modules**, linking creates every imported binding up front, so the identifier always exists — but a `const`, `let`, or `class` binding is uninitialized until the exporting module's body has run, and reading it early throws `ReferenceError: Cannot access 'x' before initialization`. Different symptoms, one root cause: a module observed its partner mid-evaluation. ## Evaluation order is decided by the entry point Which module is the unfinished one depends on where the walk started. Loading `a.js` first and loading `b.js` first can produce different orders, so the same cycle may be invisible in a unit test that imports one file and fatal in an application bundle that starts somewhere else. That order-dependence is why cycle bugs feel intermittent even though each individual run is fully deterministic. ## What is safe inside a cycle Access that happens *after* both bodies have finished is fine, because by then both records are complete. In practice that means using the cyclic import only inside a function body that gets called later: ```js // b.js — safe: fromA is only read when greet() is called, // long after both module bodies have finished evaluating. import { fromA } from './a.js'; export function greet() { return `hello ${fromA}`; } ``` The same code with `console.log(fromA)` at the top level of `b.js` would throw. Nothing about the import changed — only *when* the binding is read. ## Why teams still get bitten Cycles rarely get written on purpose. They accumulate through re-export barrel files (an `index.js` that re-exports a whole folder, imported by files inside that same folder), through grab-bag `utils` modules that reach back into feature code, and through two domain objects that legitimately reference each other. Because loading never fails outright, a cycle can sit in a codebase for months and only surface when someone adds a top-level read or changes the entry point.

  • If neither loader errors on a cycle, why do people treat circular imports as a defect worth removing?
    Because the cycle makes correctness depend on evaluation order, which depends on the entry point. The code works until someone adds a top-level read, changes the entry file, or reorders a barrel — then it fails silently in CommonJS or throws in ESM, far from the change that caused it.
  • How many times does each module in a cycle actually execute?
    Exactly once per module registry. The registration-before-execution rule means the back-edge returns the existing entry rather than re-running the file, so no module body runs twice. Distinct registries — a separate bundle, a different resolved path, or ESM and CommonJS copies of the same package — are the only way to get a second execution.
  • Does bundling a project change whether a cycle is a problem?
    It changes the symptom, not the cause. A bundler still has to pick an evaluation order for the cycle, so the same too-early read fails; and because output ordering can differ from the loader's, a cycle that worked in development can break in the built output. The fix is removing the cycle, not tuning the tool.

saying these in an interview costs you the question

  • Circular imports always throw at load time
  • Each module in a cycle runs twice, once per import
  • The loader recurses infinitely on a cycle
  • Switching from CommonJS to ESM makes cycles safe
  • Bundlers reject circular dependencies outright

context