Circular dependencies
Learn how JavaScript ES modules and CommonJS behave when files import each other, including TDZ errors, partial exports, and fixes.
- 01Trace an ESM cycleExplain why modules can link a cycle but still fail while evaluating an uninitialized const.
- 02Compare module systemsRecognize ESM temporal dead zone errors and CommonJS partial-export observations.
- 03Remove a loopMove shared work, inject a dependency, or delay a read to make a module design easier to reason about.
Two modules, one loop
A circular dependency happens when following imports from one module eventually brings you back to that same module. The graph is a loop: a.js needs b.js, and b.js needs a.js. The files are valid JavaScript, but their evaluation order now matters.
A circular dependency is an import cycle in a module graph. The graph can be loaded and linked, while a top-level read can still fail during evaluation because its value is not ready.
This lesson continues Load, link, evaluate. That earlier lesson separated graph preparation from running code. Here that separation becomes useful: ESM can connect both imports before it knows whether either module body will read a value too early.
Cycles are not automatically bugs. They are a signal to ask one focused question: what does each module need during top-level evaluation, and what can wait until a function is called later? The answer differs for ES modules and CommonJS.
Start with the smallest possible graph when debugging. Write the entry module, draw each import as an arrow, and mark every top-level read. That small map often reveals the dependency direction and the exact value requested too early.
Cycles in ES modules
ES modules first load the reachable files and link their imports and exports. Linking creates connections between names. It does not run ordinary const initializers or console.log calls yet, so a linked import can exist before its exporting module has assigned a value.
import { b } from "./b.js";export const a = "A";console.log("a sees", b);Line 1 asks for b from b.js. Line 2 declares the exported const a. Line 3 reads b after a is initialized. By itself this file looks calm, but its import means b.js evaluates before a.js can reach line 2.
import { a } from "./a.js";export const b = "B";console.log("b sees", a);Line 1 points back to a.js. Line 2 can initialize b. Line 3 is the important read: it asks for a while a.js is waiting for b.js to finish. That imported binding has a name, but the const has not been initialized.
import "./a.js";main.js starts the graph. The host evaluates b.js first because a.js depends on it. The real result is ReferenceError: Cannot access 'a' before initialization; a.js never reaches its own console.log. This is a normal synchronous ESM failure, not a race or a timing issue.
Asha needs Ravi's notes to finish homework, and Ravi needs Asha's notes to finish his. Whoever starts first finds the other page not written yet. Reading it is the problem, not the fact that the two students know each other.
- In real life: Asha needs Ravi's notes first
- In JavaScript: a.js imports b.js
- In real life: Ravi needs Asha's notes first
- In JavaScript: b.js imports a.js
- In real life: Asha's page is not written yet
- In JavaScript: const a is uninitialized
- In real life: Reading the blank page fails
- In JavaScript: An ESM TDZ read throws
Where the analogy stops: Notes are copied in real life. ESM imports are live bindings with language-defined initialization rules.
TDZ errors across modules
The temporal dead zone, or TDZ, is the period after a lexical binding is created and before its declaration initializes it. In a single file, reading a const before its declaration throws. A cycle can make the same situation happen across files.
The useful detail is that ESM does not treat this import as missing. The binding is linked and live. The runtime refuses the read because giving undefined would hide an order-dependent error. This makes a cycle easier to diagnose than silently using a wrong value.
Replay of instrumented example code. It models the ESM evaluation order and the temporal dead zone; it is not an engine debugger.
script
let tdzMessage = ""; try { uninitialized("a"); } catch (error) { tdzMessage = error.message; }const b = "B";console.log("b sees", tdzMessage); function uninitialized(name) { throw new ReferenceError("Cannot access '" + name + "' before initialization");}The replay begins with b.js because a.js requested it. It stores b, then attempts the read from a. The final frame is the actual ReferenceError message produced by the lesson model. It is a replay of instrumented example code, not an engine debugger.
Do not “fix” this by changing every const to var. var changes the symptom into undefined, but it does not explain ownership or remove the loop. First decide whether the top-level value is genuinely needed immediately.
What works in a cycle
Exported function declarations are special here. Their bindings are initialized during linking, before module evaluation reads ordinary const values. If the function body only runs after linking, an imported declaration can be called safely in a cycle.
import { getB } from "./b.js";export function getA() { return "A"; }console.log("a sees", getB());Line 1 imports getB. Line 2 declares getA as a function declaration. Line 3 calls getB after both declaration bindings have been prepared. The output from this file is a sees B.
import { getA } from "./a.js";export function getB() { return "B"; }console.log("b sees", getA());Line 1 imports getA and line 2 declares getB. When line 3 calls getA, it receives A because the declaration is ready during linking. The completed project prints b sees A and then a sees B.
import "./a.js";Replay of a function-declaration cycle. The declarations are ready before either module reads them.
script
function getB() { return "B"; } console.log("b sees", getA());console.log("a sees", getB());A second safe pattern is to read a value later. A handler, formatter, or function called after both modules finish evaluating sees initialized live bindings. Deferring a read is useful when it matches the real user action, not merely to silence an error.
import { readB } from "./a.js";console.log(readB());The entry imports readB from a.js, and a.js and b.js finish their declarations before the call. The function reads the live binding later, so the real output is AB.
CommonJS partial exports
CommonJS uses require and module.exports. require runs synchronously and caches a module object as soon as loading begins. If b.cjs requires a.cjs while a.cjs is still running, b.cjs receives the current exports object, not a future finished snapshot.
const b = require("./b.cjs");module.exports = { a: "A" };console.log("a sees", b);Line 1 asks CommonJS for b.cjs. That module asks back for a.cjs before line 2 has assigned a final exports object. a.cjs eventually logs the b object after b.cjs returns.
const a = require("./a.cjs");module.exports = { b: "B" };console.log("b sees", a);Line 1 gets the in-progress exports object from a.cjs. At that moment it is {}, so line 3 prints b sees {}. Then b.cjs finishes its own module.exports assignment and a.cjs can print a sees { b: B }.
This code is Node-only, so it intentionally has no browser Run button. The lesson test writes these files to a temporary directory and starts a child Node process. It asserts the stable printed lines, not Node's optional circular-dependency warning text.
// a.cjsexports.name = "A";const b = require("./b.cjs");module.exports = { name: "A", b }; // b.cjs reads the original exports object if it required a.cjs above.Mutating the original exports object lets earlier require callers observe added properties. Replacing module.exports on line 4 creates a new object for later callers, while an earlier caller still holds the old object. In a cycle, that split is especially confusing.
ESM and CommonJS compared
Both systems can represent a cycle, but they expose different incomplete states. ESM uses linked bindings and protects an uninitialized lexical value with the TDZ. CommonJS hands over the current exports object, even if it has no properties yet.
| System or pattern | What is available | What an early read does |
|---|---|---|
| ES modules | Imports are linked live bindings before evaluation. | An early read of uninitialized let or const throws ReferenceError. |
| CommonJS | require returns the current module.exports object immediately. | A module in progress can expose a partial object such as {}. |
| Function declarations in ESM | Their bindings are initialized during linking. | Calling an imported declaration can work while a value read at top level would fail. |
| A later read | A function runs after both modules finish evaluating. | The same live bindings can then hold their initialized values. |
The practical debugging clue follows from that table. An ESM message containing “before initialization” points toward a top-level lexical read across a cycle. A CommonJS log of an empty or incomplete object points toward a require caller that arrived before assignment completed.
Neither behavior is random. Each follows the module system's preparation rules. Once you identify the system and the read site, the next decision is design: share code elsewhere, pass a dependency in, or defer a read honestly.
Find a cycle in a graph
You do not need to guess from folder names. A teaching model can walk directed import edges and remember modules currently being visited. If it reaches a module already in that active path, the path closes a cycle.
const graph = { "a.js": ["b.js"], "b.js": ["a.js"], "main.js": ["a.js"],}; function findCycle(graph) { const visiting = new Set(); const visited = new Set(); function visit(name, path) { if (visiting.has(name)) return [...path, name]; if (visited.has(name)) return null; visiting.add(name); for (const dependency of graph[name] ?? []) { const cycle = visit(dependency, [...path, name]); if (cycle) return cycle; } visiting.delete(name); visited.add(name); return null; } return Object.keys(graph).map((name) => visit(name, [])).find(Boolean) ?? null;} console.log(findCycle(graph).join(" -> "));Line 7 creates a set for the current path. Line 10 starts a visit. Line 13 detects a return to an active module. Lines 16 through 20 explore imports, and line 24 prints the result. The real output is a.js -> b.js -> a.js.
This model is intentionally small. Real tools must resolve package exports, aliases, file extensions, and conditional imports. The simple version still teaches the central fact: a cycle is a directed path that returns to a module already being visited.
Break the dependency loop
The clearest repair moves shared work into a third module. Instead of a.js importing b.js and b.js importing a.js, both can import shared.js. The two features now meet at a shared dependency without asking each other for top-level state.
const graph = { "a.js": ["shared.js"], "b.js": ["shared.js"], "shared.js": [],}; console.log(findCycle(graph));Lines 2 and 3 give both feature modules the same shared dependency. Line 4 makes shared.js a leaf. Line 7 runs findCycle, which prints null because no active path returns to its start.
const graph = { "a.js": ["shared.js"], "b.js": ["shared.js"], "shared.js": [],}; console.log(findCycle(graph));a.js: b.js | b.js: a.js | main.js: a.jsOnly the selected edge changes.
a.js -> b.js -> a.jsA directed path returns to its starting module.
The model finds a.js -> b.js -> a.js. The two modules still point around the loop.
Another repair is dependency injection: pass a formatter, repository, or service as a function parameter instead of importing it. A third option is a lazy read inside a function, or dynamic import(), when the dependency is truly needed only after a user action.
Lazy imports are not decoration. They add an asynchronous boundary and error path. Use them when loading later reflects the product flow, such as opening an optional editor, rather than hiding a module design that should be reshaped.
After a refactor, run the smallest entry again. Confirm that every module can evaluate without reaching back into an in-progress caller. Then retain a small regression test for the output or error message, so a later top-level import cannot quietly restore the loop.
Cycles in real applications
Feature folders often produce cycles when a screen imports a store, the store imports a screen helper, and the helper imports the screen's types or constants. The names can look harmless because each import is small. The loop only becomes visible when top-level code starts reading values.
Keep module roles narrow. A shared domain module can own types and pure formatting. A service can own requests. A UI feature can call both. The direction should normally be one way: application edges point toward stable shared code, not back toward a caller.
- b.js reads imported const
abefore a.js runs its declaration. - b.cjs requires a.cjs while a.cjs is still running.
- b.js calls an imported function declaration.
- A handler reads an import after both modules evaluated.
- a.cjs replaces module.exports after b.cjs already received exports.
- a.js and b.js both import shared.js instead of each other.
Sort each card by what it describes. The explanations connect each choice to the lesson examples.
When a build tool reports a circular dependency, inspect whether it crosses a meaningful boundary or merely re-exports types. Then inspect top-level reads first. A benign cycle can still become fragile later when someone adds a const initialized from an import.
Common mistakes
- “Every cycle crashes.” A cycle can work when its top-level reads are safe.
- “An ESM TDZ read is undefined.” A lexical import read before initialization throws ReferenceError.
- “CommonJS gives final exports.” It can give the object built so far.
- “Changing const to var fixes the design.” It usually hides an early read as undefined.
- “Dynamic import is always cleaner.” It is only useful when delayed loading matches the real flow.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| A cycle is always broken by the runtime | The runtime can link a cycle, but evaluation can still fail. | Every cycle is automatically safe. |
| TDZ means missing | The binding exists but cannot be read before initialization. | The import is undefined. |
| CommonJS exports | require can return work completed so far. | The final module.exports object is already available. |
| Dynamic import | It can defer when a module is requested. | It makes every architecture problem disappear. |
The safest habit is to name the phase and module system before making a change. “ESM evaluates b.js while a is uninitialized” is actionable. “Imports are weird” is not.
For a refresher on the local language rule behind this error, read the temporal dead zone. For broader interoperability between systems, see CommonJS and ESM.
Practice exercises
Run the small model and type the directed cycle it finds.
const graph = { a: ["b"], b: ["a"] };
console.log(findCycle(graph).join(" -> "));The output is a -> b -> a. Reaching a while it is still on the active path closes the cycle.
What error does an early read of imported const a produce in the first ESM example?
The error is ReferenceError. A lexical binding is in its temporal dead zone until its declaration initializes it.
Which exported form is safe to call in the lesson's function cycle?
An exported function declaration can work across this cycle because its binding is ready during linking.
In the CommonJS example, what does b.cjs print for a before a.cjs reaches its assignment?
b.cjs sees {}. CommonJS returns the in-progress exports object.
You have validation code used by both checkout.js and receipt.js. What third module can own it?
Move shared code into shared.js, then let a.js and b.js import that module instead of each other.
Your component and its service import each other. How can the component receive the service without importing it directly?
function showOrder(formatter, order) {
console.log(formatter(order));
}
showOrder((order) => order.id, { id: "tea" });Pass the dependency as a parameter. The feature can call the supplied service without importing its caller and closing a loop.
Check your understanding
For each question, name the module system first. Then ask whether the read happens while evaluation is still in progress or after both modules have reached initialized state.
Question 1 of 7Why can ES modules represent a cycle before running module bodies?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictconst getA = () => "A"; const getB = () => getA() + "B"; console.log(getB());Choose an answer to see the explanation.
Question 3 of 7What happens when b.js reads imported const a before a.js initializes it?
Choose an answer to see the explanation.
Question 4 of 7What can CommonJS return during a require cycle?
Choose an answer to see the explanation.
Question 5 of 7Which ESM export is ready during linking?
Choose an answer to see the explanation.
Question 6 of 7Which refactor removes the edge between a.js and b.js?
Choose an answer to see the explanation.
Question 7 of 7Why is module.exports reassignment risky inside a CommonJS cycle?
Choose an answer to see the explanation.
Key takeaways
- ES modules can link an import cycle before module bodies evaluate.
- Reading an imported let or const before its exporter initializes it throws a TDZ ReferenceError.
- Function declarations and reads deferred until after evaluation can work in a cycle.
- CommonJS require can expose a partial exports object, and module.exports reassignment can split references.
- Break a cycle by moving shared code to a third module, injecting a dependency, or delaying a genuinely later read.
Remember the one-liner.
A cycle is safe only when no module reads another module's not-yet-initialized top-level value.
Coming next: Top-level await & async evaluation. A normal cycle already needs careful ordering; an awaiting module adds a new way for evaluation to pause the graph.