completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Circular dependencies

Learn how JavaScript ES modules and CommonJS behave when files import each other, including TDZ errors, partial exports, and fixes.

By the end, you can
  • 01
    Follow an ESM loopExplain why JavaScript can connect two files that import each other, yet still throw when one reads a const too early.
  • 02
    Compare the two module systemsTell an ESM ReferenceError from a CommonJS exports object that is still empty.
  • 03
    Break 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.

The main idea

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.

Three file cards. main.js imports a.js. a.js imports b.js, and b.js imports a.js back, so the two arrows between a.js and b.js make a loop.main.jsstarts hereimportsa.jsimportsimports backb.js
An arrow means "imports". 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:

  1. Write down the first file that runs, the entry file.
  2. Draw each import as an arrow.
  3. 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.

a.js imports b.jsJavaScript
import { b } from "./b.js";export const a = "A";console.log("a sees", b);
  • Line 1 imports b from b.js.
  • Line 2 makes and exports a const named a, 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.

b.js imports a.js backJavaScript
import { a } from "./a.js";export const b = "B";console.log("b sees", a);
  • Line 1 imports a from a.js. This closes the loop.
  • Line 2 sets b to "B". That works, because b belongs to this file.
  • Line 3 reads a. This is the problem line. a.js is still waiting for b.js to finish, so a.js has not run its line 2 yet. The name a is connected, but it has no value.
main.js starts the runPop out in the code editor (opens in a new tab)JavaScript
import "./a.js";

main.js is the entry file. It imports a.js. Then the files run in this order:

  1. a.js imports b.js, so b.js runs first.
  2. b.js sets b, then reads a on line 3.
  3. JavaScript throws ReferenceError: Cannot access 'a' before initialization. A ReferenceError is 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. Its line 3 reads a. The box named a is still empty, because a.js has not run its line 2 yet. The read throws a ReferenceError and the run stops.b.js line 3: console.log("b sees", a)b.jsreads aaempty: not set yetReferenceErrorthe run stops
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.
Real-life analogyAsha and Ravi's notes

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.

Step through the early read
Step 0 of 3Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
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");}
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

The step-through uses a stand-in program, because a real cycle needs two files:

  • Line 2 stands for b.js setting b.
  • Line 4 stands for the early read of a. The helper uninitialized throws 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.

a.js exports a function declarationJavaScript
import { getB } from "./b.js";export function getA() { return "A"; }console.log("a sees", getB());
  • Line 1 imports getB from b.js.
  • Line 2 makes and exports the function getA. It returns "A".
  • Line 3 calls getB() and prints a sees B.
b.js exports a function declaration tooJavaScript
import { getA } from "./a.js";export function getB() { return "B"; }console.log("b sees", getA());
  • Line 1 imports getA from a.js.
  • Line 2 makes and exports the function getB. It returns "B".
  • Line 3 calls getA(). a.js has not run yet, but getA was set up during linking. So the call works and prints b sees A.
main.js starts the function cycleJavaScript
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.

Step through a loop of two functions
Step 0 of 4Ready
Your turn: follow the blue line

A replay of a loop with two functions. Both are ready before either file calls them.

Running in
  1. script
Next: line 1
function getB() { return "B"; } console.log("b sees", getA());console.log("a sees", getB());
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Read the values later, inside a functionPop out in the code editor (opens in a new tab)JavaScript
import { readB } from "./a.js";console.log(readB());

This entry file uses two other files that import each other:

  • a.js imports getB from b.js. It exports getA, which returns "A", and readB, which calls getB().
  • b.js imports getA from a.js. It exports getB, which returns getA() + "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.exports is 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.

a.cjs requires b.cjsJavaScript
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.exports with a new object, { a: "A" }.
  • Line 3 prints b, after b.cjs has finished.
b.cjs requires a.cjs backJavaScript
const a = require("./a.cjs");module.exports = { b: "B" };console.log("b sees", a);
  • Line 1 requires a.cjs. But a.cjs is still waiting on its line 1. Its exports object is still empty, so a gets {}.
  • 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.

b.cjs line 1 asks for a.cjs. It gets a.cjs's exports object, drawn as an empty cart, because a.cjs has not reached its line 2 yet. So b.cjs prints b sees {}.b.cjs line 1: require("./a.cjs")b.cjsgets aa.cjsb sees {}empty so far
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.

Replacing module.exports splits the object in twoJavaScript
// 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 name to the first exports object.
  • Line 3 requires b.cjs. If b.cjs requires a.cjs back, it gets that first object: { name: "A" }.
  • Line 4 replaces module.exports with a brand-new object. Files that require a.cjs after 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.

What an early read does in each case
CaseWhat is readyWhat an early read does
ES modulesEvery import is connected before any code runs.Reading a let or const before its line runs throws a ReferenceError.
CommonJSrequire 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 ESMThey are set up before any line runs.Calling one works, even when reading a const at the top level would throw.
A later readA 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 let or const from 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.

A small program that finds a loopPop out in the code editor (opens in a new tab)JavaScript
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, a Set (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.

Both files import shared.js insteadPop out in the code editor (opens in a new tab)JavaScript
const graph = {  "a.js": ["shared.js"],  "b.js": ["shared.js"],  "shared.js": [],}; console.log(findCycle(graph));
  • Lines 2 and 3: a.js and b.js each import only shared.js.
  • Line 4: shared.js imports nothing, so no arrow leads back out of it.
  • Line 7 runs findCycle from the last section. It prints null, which means "no loop found".
Left: a.js and b.js import each other, and findCycle prints a.js -> b.js -> a.js. Right: a.js and b.js both import shared.js, which imports nothing, and findCycle prints null.import each othera.jsb.jsa.js -> b.js -> a.jsboth import shared.jsa.jsb.jsshared.jsnull: no loop
On the left, the two files import each other, so there is a loop. On the right, both import shared.js instead, and findCycle finds no loop.
Playground: move the shared code to shared.js
Find a loop in the importsPop out in the code editor (opens in a new tab)JavaScript
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(" -> "));
Resultloop found
importsa.js: b.js | b.js: a.js | main.js: a.js

a.js imports b.js, and b.js imports a.js.

findCyclea.js -> b.js -> a.js

The path comes back to a file it already visited.

Try it yourself

findCycle finds a.js -> b.js -> a.js. The two files still import each other, so the arrows go round in a loop.

The code and the result change with the buttons. Both use findCycle from the last section. This only checks the import arrows; it does not load any files.

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:

  1. A screen file imports a store file, which holds the app's data.
  2. The store imports a helper file.
  3. 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.

What happens in each case?
  • b.js reads the imported const a before 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.
Try it yourself
0 of 6 correct

Put each card in the group that says what happens. Read the explanation after each card.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

When a build tool warns you about a circular dependency:

  1. Check if the loop matters. A loop that only shares types is often harmless.
  2. 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 imported let or const before its line runs throws a ReferenceError.
  • “CommonJS always gives the finished exports.” In a cycle, it can give the object as it is so far, even an empty one.
  • “Changing const to var fixes it.” It only turns the error into undefined. 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.

Ideas that are easy to mix up
IdeaWhat it meansWhat it does not mean
A loop in the importsJavaScript 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 looprequire 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

Exercise 1 · Warm-upPredict the loop

This graph has two files, a and b. Run it with findCycle from the lesson, then type the loop it prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const graph = { a: ["b"], b: ["a"] };
console.log(findCycle(graph).join(" -> "));

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the ESM error

    In the first ESM example, b.js reads the imported const a too early. What type of error does that throw?

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeChoose the safe kind of export

      In the safe example, b.js calls getA() before a.js runs. What kind of export makes that work?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticePredict the half-built export

        In the CommonJS example, what does b.cjs get for a on its line 1?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMove the shared part

          checkout.js and receipt.js import each other, because both need the same code that checks an order. Where should that code go?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeApply it to a UI feature

            A component and its service import each other. How can the component use the service without importing it? The starter code shows one way.

            Starter codePop out in the code editor (opens in a new tab)JavaScript
            function showOrder(formatter, order) {
              console.log(formatter(order));
            }
            showOrder((order) => order.id, { id: "tea" });

            Answer, then press Check. Spacing and letter case don’t matter.

              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?

              Circular dependencies quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why can JavaScript connect two ES modules that import each other?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const getA = () => "A";
                const getB = () => getA() + "B";
                console.log(getB());

                Choose an answer to see the explanation.

              3. 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.

              4. Question 4 of 7In a CommonJS loop, what can require give back?

                Choose an answer to see the explanation.

              5. Question 5 of 7Which ESM export is ready before any line runs?

                Choose an answer to see the explanation.

              6. Question 6 of 7Which change removes the loop between a.js and b.js?

                Choose an answer to see the explanation.

              7. 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 let or const before its line runs throws a ReferenceError. 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, require can give back an exports object that is still empty. Replacing module.exports splits 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.

              CompleteFrontend Clear concepts. Working examples.