Circular dependencies
Learn how JavaScript ES modules and CommonJS behave when files import each other, including TDZ errors, partial exports, and fixes.
- 01Follow an ESM loopExplain why JavaScript can connect two files that import each other, yet still throw when one reads a const too early.
- 02Compare the two module systemsTell an ESM ReferenceError from a CommonJS exports object that is still empty.
- 03Break a loopMove shared code to a third file, pass a function in, or read a value later.
Two modules, one loop
A module is one JavaScript file that can share values with other files. A dependency is a file that another file imports. Sometimes two files import each other: a.js imports b.js, and b.js imports a.js. If you follow the imports, you come back to where you started. That is a circular dependency, also called an import cycle.
A circular dependency is a loop of imports between files. JavaScript can connect all the imports in the loop without a problem. The trouble comes later, when the code runs: a file may read a value from the other file before that value has been set.
a.js and b.js import each other, so the arrows between them go round in a loop.This lesson builds on Load, link, evaluate. That lesson showed three steps for modules:
- Load: find and read every file.
- Link: connect each import to its export.
- Evaluate: run the code, imported files first.
Linking happens before any code runs. So JavaScript can connect both imports in a loop before it knows if a file will read a value too early.
A cycle is not always a bug. It is a reason to ask one question: which values does each file read as soon as it runs, and which can wait until a function is called later? The answer is different for ES modules (files that use import and export) and CommonJS (files that use require, mostly in Node.js).
When you hunt for a cycle bug, start small:
- Write down the first file that runs, the entry file.
- Draw each import as an arrow.
- Mark every value a file reads at the top level, outside any function. That code runs as soon as the file runs.
This small map often shows you the value that was read too early.
Cycles in ES modules
ES modules (ESM for short) are files that use import and export. Before any code runs, JavaScript loads every file and links each import to its export. Linking only connects names. It does not run any lines yet: no const gets its value, and no console.log prints. So an import can be connected before the other file has given it a value.
Here are three small files that make a loop.
import { b } from "./b.js";export const a = "A";console.log("a sees", b);- Line 1 imports
bfromb.js. - Line 2 makes and exports a
constnameda, set to"A". - Line 3 prints
b.
This file looks fine on its own. But because of line 1, b.js runs first, before a.js gets to line 2.
import { a } from "./a.js";export const b = "B";console.log("b sees", a);- Line 1 imports
afroma.js. This closes the loop. - Line 2 sets
bto"B". That works, becausebbelongs to this file. - Line 3 reads
a. This is the problem line.a.jsis still waiting forb.jsto finish, soa.jshas not run its line 2 yet. The nameais connected, but it has no value.
import "./a.js";main.js is the entry file. It imports a.js. Then the files run in this order:
a.jsimportsb.js, sob.jsruns first.b.jssetsb, then readsaon line 3.- JavaScript throws
ReferenceError: Cannot access 'a' before initialization. AReferenceErroris the error for a name you can't use right now.
The run stops there. a.js never reaches its own console.log. This happens the same way every time. It is not about timing or luck.
b.js runs first, so the box for a is still empty when line 3 reads it. JavaScript throws a ReferenceError instead of giving back an empty value.Asha and Ravi share homework notes. Asha needs Ravi's notes before she can write hers. Ravi needs Asha's notes before he can finish his. Ravi starts first. He opens Asha's page, but it is still blank. He can't copy anything from it. Sharing notes is fine. Reading a blank page is the problem.
- 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 still blank
- In JavaScript: const a has no value yet
- In real life: Ravi can't copy a blank page
- In JavaScript: Reading a throws a ReferenceError
TDZ errors across modules
A let or const name exists from the start of its scope, but it has no value until its line runs. The time in between is called the temporal dead zone, or TDZ. Reading the name during that time throws a ReferenceError.
In one file, you see this when you read a const above its line. A cycle makes the same thing happen across two files: b.js reads a while a is still in its TDZ.
ESM does not treat the import as missing. The name a is connected to a.js. JavaScript just refuses to read it yet. Why not give undefined instead? Because then your code would quietly carry on with a wrong value. An error tells you right away where the problem is.
A replay of a stand-in program. It shows the run order and the early-read error. It is not a view inside the JavaScript engine.
script
const b = "B";let tdzMessage = "";try { uninitialized("a"); } catch (error) { tdzMessage = error.message; }console.log("b sees", tdzMessage); function uninitialized(name) { throw new ReferenceError("Cannot access '" + name + "' before initialization");}The step-through uses a stand-in program, because a real cycle needs two files:
- Line 2 stands for
b.jssettingb. - Line 4 stands for the early read of
a. The helperuninitializedthrows the same error that JavaScript throws.
The stand-in catches the error so you can see the message. The real files stop at the ReferenceError.
Don't "fix" this by changing every const to var. With var, the early read gives undefined instead of an error. The loop is still there, and now the bug is hidden. First ask: does this file really need the value as soon as it runs?
What works in a cycle
Some imports are safe in a cycle. The first safe kind is a function declaration: a function made with the function keyword, like function getA() { ... }. JavaScript sets up these functions during linking, before any line runs. So another file can call them even while the loop is still running.
import { getB } from "./b.js";export function getA() { return "A"; }console.log("a sees", getB());- Line 1 imports
getBfromb.js. - Line 2 makes and exports the function
getA. It returns"A". - Line 3 calls
getB()and printsa sees B.
import { getA } from "./a.js";export function getB() { return "B"; }console.log("b sees", getA());- Line 1 imports
getAfroma.js. - Line 2 makes and exports the function
getB. It returns"B". - Line 3 calls
getA().a.jshas not run yet, butgetAwas set up during linking. So the call works and printsb sees A.
import "./a.js";b.js runs first again. The whole run prints b sees A, then a sees B. The step-through below shows both functions ready before either file runs.
A replay of a loop with two functions. Both are ready before either file calls them.
script
function getB() { return "B"; } console.log("b sees", getA());console.log("a sees", getB());The second safe kind is a later read: reading the import inside a function that runs after both files have finished. By then every value is set. Use this when the read really belongs to something that happens later, such as a click. Don't use it just to make an error go away.
import { readB } from "./a.js";console.log(readB());This entry file uses two other files that import each other:
a.jsimportsgetBfromb.js. It exportsgetA, which returns"A", andreadB, which callsgetB().b.jsimportsgetAfroma.js. It exportsgetB, which returnsgetA() + "B".
Neither file reads the other's values while it runs. They only make functions. Line 1 imports readB. Line 2 calls it after both files have finished. By then getA and getB are both set, so it prints AB.
CommonJS partial exports
CommonJS is the older module system in Node.js. Its files often end in .cjs. It has two tools:
require("./b.cjs")runs another file right away and gives back what that file exports.module.exportsis the object a file gives to anyone who requires it. It starts as an empty object,{}.
Node.js remembers each file's exports object as soon as the file starts running. So if b.cjs requires a.cjs while a.cjs is still running, b.cjs gets the object as it is right now. It does not wait for the finished one.
const b = require("./b.cjs");module.exports = { a: "A" };console.log("a sees", b);- Line 1 requires
b.cjs. That file runs now, before line 2. - Line 2 replaces
module.exportswith a new object,{ a: "A" }. - Line 3 prints
b, afterb.cjshas finished.
const a = require("./a.cjs");module.exports = { b: "B" };console.log("b sees", a);- Line 1 requires
a.cjs. Buta.cjsis still waiting on its line 1. Its exports object is still empty, soagets{}. - Line 2 sets this file's exports to
{ b: "B" }. - Line 3 prints
b sees {}.
Then b.cjs is done, and a.cjs carries on. It prints a sees { b: 'B' }. There is no error. You just get an object with nothing in it yet.
a.cjs is still waiting on its line 1, so its exports object is still empty. b.cjs gets that empty object and prints b sees {}.This code only runs in Node.js, so it has no Run button here. The lesson's tests run these two files in Node.js and check both printed lines.
// a.cjsexports.name = "A";const b = require("./b.cjs");module.exports = { name: "A", b }; // If b.cjs required a.cjs above, it still holds the first exports object.- Line 2 adds
nameto the first exports object. - Line 3 requires
b.cjs. Ifb.cjsrequiresa.cjsback, it gets that first object:{ name: "A" }. - Line 4 replaces
module.exportswith a brand-new object. Files that requirea.cjsafter this get the new object.
Now there are two objects. b.cjs still holds the first one, so it never sees the new b property. To avoid this, add properties to exports instead of replacing module.exports.
ESM and CommonJS compared
Both module systems allow a cycle. But an early read goes wrong in different ways:
- In ESM, the name is connected but has no value yet. Reading it throws a
ReferenceError. - In CommonJS, you get the exports object as it is right now. It may still be empty.
Each row of this table is one case. Read across to see what is ready and what an early read does.
| Case | What is ready | What an early read does |
|---|---|---|
| ES modules | Every import is connected before any code runs. | Reading a let or const before its line runs throws a ReferenceError. |
| CommonJS | require gives back the file's module.exports object right away. | If that file is still running, you get the object as it is so far, such as {}. |
| Function declarations in ESM | They are set up before any line runs. | Calling one works, even when reading a const at the top level would throw. |
| A later read | A function runs after both files have finished. | Nothing goes wrong: every value is set by then. |
The table gives you two clues for finding bugs:
- An ESM error that says "before initialization" means a file read a
letorconstfrom another file too early, at its top level. - A CommonJS log of an empty or half-filled object means a file required another file before that file had set its exports.
Neither one is random. Each follows its module system's rules. Once you know the system and the line that reads too early, choose a fix: move shared code to its own file, pass a function in, or read the value later.
Find a cycle in a graph
A module graph is the list of files and the arrows between them, where an arrow means "imports". You don't need to guess where a loop is. A short program can follow the arrows for you. It keeps a list of the files it is in the middle of visiting. If it reaches a file that is already on that list, it has gone round a loop.
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(" -> "));- Lines 1 to 5 write the graph as an object. Each file name lists the files it imports.
- Line 8 makes
visiting, aSet(a list with no repeats) of the files on the current path. - Line 10 starts
visit, which walks from one file. - Line 11 checks if the file is already on the path. If it is, that is a loop, so it returns the path.
- Lines 14 to 17 follow each import, one by one.
- Line 25 prints the loop it found:
a.js -> b.js -> a.js.
This program is small on purpose. Real tools also handle things like package names and file endings. But the main idea is the same: a cycle is a path of imports that comes back to a file already on the path.
Break the dependency loop
The clearest fix is a third file. Find the code that both files need, and move it into its own file, such as shared.js. Then a.js and b.js both import shared.js, instead of importing each other.
const graph = { "a.js": ["shared.js"], "b.js": ["shared.js"], "shared.js": [],}; console.log(findCycle(graph));- Lines 2 and 3:
a.jsandb.jseach import onlyshared.js. - Line 4:
shared.jsimports nothing, so no arrow leads back out of it. - Line 7 runs
findCyclefrom the last section. It printsnull, which means "no loop found".
shared.js instead, and findCycle finds no loop.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(" -> "));a.js: b.js | b.js: a.js | main.js: a.jsa.js imports b.js, and b.js imports a.js.
a.js -> b.js -> a.jsThe path comes back to a file it already visited.
findCycle finds a.js -> b.js -> a.js. The two files still import each other, so the arrows go round in a loop.
There are two more ways to fix a loop:
- Pass it in. Give the function it needs as a parameter, instead of importing it. This is called dependency injection.
- Load it later. Read the value inside a function, or load the file with
import()when a user action needs it.import()loads a file while the page runs and gives back a promise.
Loading later has a cost. Your code must wait for the promise, and it must handle the case where loading fails. So use it only when the file really is needed later, such as when the user opens an editor. Don't use it to hide a loop that a third file would fix.
After a fix, run the smallest entry file again. Check that no file reads from a file that is still running. Then keep a small test that checks the output, so a new import can't bring the loop back without anyone noticing.
Cycles in real applications
In a real app, a loop often grows by accident, one small import at a time. For example:
- A screen file imports a store file, which holds the app's data.
- The store imports a helper file.
- The helper imports a constant from the screen file.
Now the screen imports itself through two other files. Each import looks harmless. You only notice the loop when one file reads another's value as soon as it runs.
Give each file one clear job, and keep imports pointing one way:
- A shared file holds types and simple helpers, like a price formatter.
- A service file sends requests to the server.
- A screen file imports both.
Imports should point from the screen down to the shared files, never back up to the screen.
- b.js reads the imported const
abefore a.js has run that line. - b.cjs requires a.cjs while a.cjs is still running.
- b.js calls an imported function made with the function keyword.
- A click handler reads an import after both files have finished.
- a.cjs replaces module.exports after b.cjs already got its exports.
- a.js and b.js both import shared.js instead of each other.
Put each card in the group that says what happens. Read the explanation after each card.
When a build tool warns you about a circular dependency:
- Check if the loop matters. A loop that only shares types is often harmless.
- Look for values that a file reads as soon as it runs.
A harmless loop can still break later. All it takes is a new const at the top level that reads an imported value.
Common mistakes
- “Every cycle crashes.” A cycle works fine when no file reads another file's value too early.
- “An early ESM read gives
undefined.” Reading an importedletorconstbefore its line runs throws aReferenceError. - “CommonJS always gives the finished exports.” In a cycle, it can give the object as it is so far, even an empty one.
- “Changing
consttovarfixes it.” It only turns the error intoundefined. The loop is still there. - “
import()is always the cleaner fix.” It only helps when the file really is needed later, such as after a click.
Each row of this table is an idea that is easy to mix up. Read across to see what it really means and what it does not mean.
| Idea | What it means | What it does not mean |
|---|---|---|
| A loop in the imports | JavaScript can connect it, but a file can still read a value too early. | Every loop is safe, or every loop crashes. |
| TDZ (temporal dead zone) | The name exists, but it has no value until its line runs. | The import is missing or undefined. |
| CommonJS exports in a loop | require can give back the object as it is so far. | You always get the finished module.exports object. |
| import() | It loads a file later, when your code asks for it. | It fixes every loop for free. |
Before you change any code, say which module system it is and which line reads too early. "In ESM, b.js reads a before a.js has set it" tells you what to fix. "Imports are weird" does not.
To review the TDZ rule in one file, read the temporal dead zone. To learn how the two module systems work together, see CommonJS and ESM.
Practice exercises
This graph has two files, a and b. Run it with findCycle from the lesson, then type the loop it prints.
const graph = { a: ["b"], b: ["a"] };
console.log(findCycle(graph).join(" -> "));It prints a -> b -> a. The walk starts at a, goes to b, then reaches a again while a is still on the path. That is the loop.
In the first ESM example, b.js reads the imported const a too early. What type of error does that throw?
It is a ReferenceError. The const a exists, but it has no value until a.js runs its line 2. Reading it before then throws.
In the safe example, b.js calls getA() before a.js runs. What kind of export makes that work?
A function declaration. JavaScript sets it up during linking, so the other file can call it even before this file runs.
In the CommonJS example, what does b.cjs get for a on its line 1?
b.cjs sees {}. CommonJS gives back the exports object as it is right now, and it is still empty.
checkout.js and receipt.js import each other, because both need the same code that checks an order. Where should that code go?
Move the shared code into a third file such as shared.js. Then checkout.js and receipt.js both import shared.js instead of each other.
A component and its service import each other. How can the component use the service without importing it? The starter code shows one way.
function showOrder(formatter, order) {
console.log(formatter(order));
}
showOrder((order) => order.id, { id: "tea" });Pass it in as a parameter. In the starter code, showOrder gets formatter as a parameter, so it never imports the file that made it. That is dependency injection.
Check your understanding
For each question, first say which module system it is: ESM or CommonJS. Then ask: does the read happen while a file is still running, or after both files have finished?
Question 1 of 7Why can JavaScript connect two ES modules that import each other?
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 the imported const a before a.js has set it?
Choose an answer to see the explanation.
Question 4 of 7In a CommonJS loop, what can require give back?
Choose an answer to see the explanation.
Question 5 of 7Which ESM export is ready before any line runs?
Choose an answer to see the explanation.
Question 6 of 7Which change removes the loop between a.js and b.js?
Choose an answer to see the explanation.
Question 7 of 7Why is replacing module.exports risky in a CommonJS loop?
Choose an answer to see the explanation.
Key takeaways
- A circular dependency is a loop of imports. ES modules connect every import in the loop before any file runs.
- Reading an imported
letorconstbefore its line runs throws aReferenceError. The name is in its temporal dead zone (TDZ). - Function declarations are safe in a cycle, and so are reads inside a function that runs after both files finish.
- In CommonJS,
requirecan give back an exports object that is still empty. Replacingmodule.exportssplits it into two objects. - To break a loop, move the shared code to a third file, pass a function in, or load the file later when it really is needed later.
Remember the one-liner.
A cycle is safe as long as no file reads another file's value before that value is set.
Coming next: Top-level await & async evaluation. There you will see a file use await at its top level. That lets a file pause in the middle of running, which changes the run order again.