skip to content

A large JavaScript codebase intermittently sees an imported value arrive as undefined, or throws 'Cannot access before initialization', depending on which entry point loads first. How do you confirm an import cycle is the cause, and how do you break it?

level: seniorimportance: should knowfreq 33%

answer

  1. same modules, different entry point
  2. enumerate the graph, do not eyeball
  3. barrel files manufacture invisible loops
  4. extract a third module both point at
  5. lint rule in CI stops regression

basics

~20 s

Map the import graph and look for a loop — a tool such as madge run with --circular, or ESLint's import/no-cycle rule, lists them mechanically. Then break the loop by extracting the shared code into a third module, or by inverting the dependency, rather than by reordering imports.

solid answer

~50 s

First confirm order-dependence: reproduce with the failing entry point, note that the same modules behave differently when a different file loads first, and read the stack to find which module was mid-evaluation. Then get the graph mechanically — `madge --circular src` or the `import/no-cycle` rule from eslint-plugin-import will enumerate every loop, which is the only reliable way to find one that runs through five files. Fixes, in order of durability: extract the piece both modules need into a third module neither imports back; invert the dependency so the lower-level module stops reaching upward, passing what it needs in as an argument or registering it; or, as a stopgap, defer the access into a function body so the read happens after both modules have evaluated. Then keep the lint rule on in CI so the loop cannot come back.

go deeper

for a junior

Know that cycle detection is mechanical, not detective work: a tool like madge or an ESLint rule lists every loop in the import graph, so you never hunt for one by reading files.

for a middle

Explain why extracting the shared piece into a third module truly removes the loop, while shuffling import lines only changes evaluation order and often does not change even that.

for a senior

Demonstrate the full loop: reproduce under the failing entry point, confirm order dependence, choose a fix that matches the real coupling, then add the lint rule so the same loop cannot come back.

for a principal

Own the prevention side — whether cycles are hard-blocked in CI, how re-export barrels are constrained, and what it costs to enable that rule across a mature codebase with dozens of existing violations.

## Confirming the diagnosis The signature of a cycle is a value that is *structurally* correct — the export exists, the path is right, the spelling is right — but arrives empty or unreadable, and whose behaviour changes with load order. Three checks settle it: 1. **Reproduce under the failing entry point.** If importing the same module from a small scratch file works but the application fails, evaluation order is implicated, and evaluation order is decided by the entry. 2. **Read what the failure actually says.** `Cannot access 'x' before initialization` in native ESM means the binding existed and was uninitialized — that is a mid-evaluation read, not a missing export. In CommonJS the equivalent tell is `undefined` or `{}` where an object was expected, with no error at the import site at all. 3. **Enumerate the cycles.** Do not eyeball it. `npx madge --circular src` prints every loop in the graph; the `import/no-cycle` rule in `eslint-plugin-import` reports them as lint errors at the offending import statement, which is more useful because it points at a line. ## Where cycles actually come from In practice a handful of patterns produce most of them: - **Re-export barrels.** An `index.js` that re-exports every file in a folder, imported by files *inside* that folder, creates a loop through the barrel even though no two source files reference each other directly. Importing a sibling by deep path instead of through the barrel removes the loop outright. - **Grab-bag utility modules.** A `utils` module that imports a feature module to reuse one constant, while the feature imports `utils` for everything else. - **Mutually referencing domain objects.** `Order` needs `Customer`, `Customer` lists its `Order`s. The reference is genuine; the file layout is what makes it circular. - **A base class and a factory** that construct each other, which in ESM fails hardest because `extends` evaluates in module-body position. ## Fixes, most durable first **Extract to a third module.** Find what the two modules actually share — a type shape, a constant, a small helper — and move it into a module that imports neither. Both then point down at the new module, and the loop is gone from the graph rather than merely tolerated. This is the fix that survives future edits, because a new top-level read cannot reintroduce a cycle that no longer exists. **Invert the direction.** If the loop exists because a lower-level module reaches back up into higher-level code, stop importing and take the dependency as an argument, a constructor parameter, or a value registered at startup. The lower module keeps working without knowing who supplies it. **Defer the access.** Read the cyclic binding inside a function rather than at module top level (in CommonJS, move the `require` call itself inside the function). This works because both bodies have finished by the time the function is called, and imported ESM bindings are live. It is a legitimate stopgap for a large refactor, but the cycle remains, the next top-level read fails again, and a CommonJS lazy require moves a resolution failure from startup to first call. Record it as debt. ## What does not work Moving an `import` statement to the bottom of the file changes nothing, because ESM imports are hoisted and the graph is linked before evaluation. Wrapping the read in `try`/`catch` converts a loud failure into a wrong value. Converting the file to CommonJS "to avoid the error" trades a `ReferenceError` at the exact bad read for a silent `undefined` that fails elsewhere — strictly worse for debugging. ## Preventing regression Turn `import/no-cycle` on in CI once the existing loops are fixed; enabling it on a mature codebase usually surfaces dozens at once, so land it with a bounded allowlist and burn the list down. Constrain barrels: export the public surface of a package from its barrel, and forbid files inside the package from importing through it. And treat every new cycle report as a design signal — two modules that need each other's internals are usually one module, or two modules missing a third. ## When leaving a cycle is defensible If every cross-import is read only inside functions invoked well after load, the cycle is inert today. That is a real state, not a bug in production. But it is a *latent* one: it depends on nobody adding a top-level read and on the entry point not changing, neither of which a reviewer reliably notices. Leave it only with a lint exemption that names the reason, so the next person meets the decision rather than the crash.

  • Why can the same cycle be harmless under one entry point and fatal under another?
    Because evaluation order is a depth-first walk from the entry module, and the member that evaluates first is the one that can observe an unfinished partner. Change the entry and the order flips, so a cycle that was inert becomes a too-early read. Each run is deterministic; only the graph traversal moved.
  • How do barrel files create cycles that the source files themselves do not have?
    A barrel `index.js` re-exports the whole folder, so importing from it pulls in every sibling. When a file *inside* the folder imports through that barrel, the barrel imports the file back and a loop forms — even though no two source modules reference each other. Importing siblings by direct path removes it.
  • When is leaving a known cycle in place a defensible engineering choice?
    When every cross-import is read only inside functions called after load, so nothing is observed mid-evaluation, and untangling it would mean a large refactor of genuinely mutual domain objects. It is a latent hazard, though: record it as an explicit lint exemption with a reason, so the next top-level read is a conscious decision rather than a production incident.

saying these in an interview costs you the question

  • Reorder the import statements and the cycle goes away
  • Wrap the failing read in try/catch and retry
  • Cycles are fine because the bundler resolves them
  • Convert the file to CommonJS to avoid the error
  • Only a full rewrite can remove a circular dependency

context