Cyclic Module Dependencies

A cyclic module dependency occurs when modules form a dependency cycle, such as A importing B while B imports A. Cycles are permitted in JavaScript modules, but evaluation order and initialization timing can expose bindings before they are initialized and cause runtime errors.

JavaScript modules are represented as a dependency graph. An edge from module A to module B means A depends on B, and a cycle exists when following dependencies eventually leads back to an earlier module. For example, `a.js -> b.js -> a.js` is a cyclic dependency.

Cycles are not automatically invalid. JavaScript's module system creates module records and links imported bindings before evaluating module bodies. Imports are live bindings rather than copied values, so a module can observe another module's binding after that binding has been initialized.

The important hazard is evaluation and initialization timing. A lexical binding declared with `let`, `const`, or `class` cannot be accessed before its initialization. If a cyclic module evaluates code that reads such an imported binding too early, the access can throw a `ReferenceError` because the binding is still in its temporal dead zone.

A practical way to handle cycles is to distinguish a structural cycle from an actual initialization problem. A cycle may work if the modules only use each other's exports after initialization, but it becomes fragile when module-level code immediately consumes an imported value. Moving shared state or pure utilities into a third module can remove the cycle and make initialization order easier to reason about.

Key points

Interview questions

Are circular dependencies allowed in JavaScript ES modules?

Yes. An ES module graph may contain cycles, so a cycle is not automatically a module-system error. The important issue is whether evaluation requires a binding before that binding has been initialized. Therefore, the presence of a cycle should prompt an initialization-order analysis rather than an assumption that the code is invalid.

Why can a cyclic import cause a `ReferenceError`?

ES modules establish imported bindings before evaluating module bodies, but that does not mean every lexical export already has a value. A `let`, `const`, or `class` binding remains uninitialized until its declaration is evaluated. If a cyclic path causes another module to read that binding first, the access can hit the temporal dead zone and throw a `ReferenceError`.

How would you diagnose a circular dependency in a frontend codebase?

I would first inspect the module dependency graph and identify the cycle, for example A imports B, B imports C, and C imports A. Then I would determine whether the cycle is merely structural or whether module-level execution depends on an export being initialized. I would inspect top-level calls, exported constants, side effects, and initialization code along the cycle. If the cycle is unnecessary, I would usually extract shared functionality or state into a separate module.

How can you refactor a cyclic dependency between two modules?

First I would identify the responsibility that causes each module to depend on the other. If both modules share types, constants, or pure utilities, I would move those pieces into a third module that neither higher-level module needs to import back from. If the dependency represents bidirectional runtime behavior, I would consider introducing an interface, callback, event boundary, or dependency-injection layer instead of direct mutual imports. The goal is not merely to eliminate a graph cycle, but to establish a clearer ownership and initialization boundary.

Can a cyclic dependency ever be intentional and safe?

Yes. A cycle can be intentional when the modules expose bindings that are consumed only after the relevant initialization has completed, and the resulting behavior is well understood. The risk increases when module evaluation performs immediate reads, constructs objects, or triggers side effects across the cycle. In a codebase, I would document intentional cycles and test the initialization path rather than assuming that all cycles are harmless.