Circular Dependencies in JavaScript Explained
Wide-eyed, I stared at undefined
. It shouldn’t have been there. It was meant to be a string, defined as export const val = 'abc'
and imported as import {val} from './my-module'
. Yet, it was undefined
. No amount of deleting node_modules
, restarting my computer, or logging could fix it. val
remained stubbornly undefined
. But why?
Sometimes you enjoy the flexibility of JavaScript/TypeScript, and other times, it kicks you in the crotch. A proper swing with a run-up. This post is about the crotch-kicking quality of JavaScript.
TL;DR
I’ll explain circular dependency types in JavaScript and how to avoid them with the import/no-cycle
ESLint rule.
Circular Dependency Explained
The simplest circular dependency is when module A references module B, and module B references module A.
However, in JavaScript, there are two circular dependency types, one more harmful than the other.
Immediate Circular Dependency
It’s called immediate (or “static”) because the dependency is realized (immediately) when the modules load, before any non-import code runs.
This is the hardcore nasty type. With some node module implementations, it will break your app completely, while with others, it will manifest as the tricky undefined
bug I teased in the introduction.
The immediate circular dependency can’t be initialized correctly.
This is the simplest example:
// a.js
import { b } from "./b.js";
export const a = "aloha";
console.log("from a", b);
// b.js
import { a } from "./a.js";
export const b = "bye";
console.log("from b", a);
// index.js
import { a } from "./a.js";
console.log(a);
As in the diagram, module a
needs module b
and vice versa.
When you run node index.js
, most of the code runs as a result of the import { a } from "./a.js"
import statement, before the “production code” console.log(a)
runs.
It happens like this:
node index.js
- We start importing
a
toindex
- When loading
a
, we need to importb
b
tries to importa
, causing a bug becausea
has already been loaded and still isn’t initialized. It waits forb
initialization.
This same code produces 3 different results based on how you run it:
ECMAScript Modules
When using ECMAScript modules, running the script fails:
file:///circular/init-circular-esm/b.js:5
console.log("from b", a);
^
ReferenceError: Cannot access 'a' before initialization
at file:///circular/init-circular-esm/b.js:5:23
at ModuleJob.run (node:internal/modules/esm/module_job:222:25)
at async ModuleLoader.import (node:internal/modules/esm/loader:323:24)
at async loadESM (node:internal/process/esm_loader:28:7)
at async handleMainPromise (node:internal/modules/run_main:120:12)
Node.js v21.7.3
This is fantastic. So if you use ESM and Node, you don’t have to worry about immediate circular dependencies.
CommonJS Modules
To use CommonJS modules, we need to change the example slightly:
// a.js
const b = require("./b.js");
module.exports = "aloha";
console.log("from a", b);
// b.js
const a = require("./a.js");
module.exports = "bye";
console.log("from b", a);
// index.js
const a = require("./a.js");
console.log(a);
Running node index.js
results in:
from b {}
from a bye
aloha
But you also get a warning:
(node:90636) Warning: Accessing non-existent property 'Symbol(nodejs.util.inspect.custom)' of module exports inside circular dependency
(Use `node --trace-warnings ...` to show where the warning was created)
(node:90636) Warning: Accessing non-existent property 'constructor' of module exports inside circular dependency
(node:90636) Warning: Accessing non-existent property 'Symbol(Symbol.toStringTag)' of module exports inside circular dependency
That’s nice. Node doesn’t clearly tell you where the issue is (even if you run it with --trace-warnings
), but at least you know you have an issue.
esbuild
I left the best for last. Our example bundled by esbuild
results in:
from b undefined
from a bye
aloha
No error, no warning, just the undefined
I alluded to at the start of this post.
esbuild
makes a performance optimization and replaces all top-level const
with var
in the bundled code. This means you can access any module top-level variable even before the module initializes, but it will be undefined
.
For completeness, this is the bundled code produced by esbuild
:
// b.js
var b = "bye";
console.log("from b", a);
// a.js
var a = "aloha";
console.log("from a", b);
// index.js
console.log(a);
Delayed Circular Dependency
Sometimes called dynamic, delayed circular dependency is more benign than its immediate cousin because the modules can still initialize correctly.
Delayed circular dependency happens when the module’s top-level code doesn’t have circular dependencies, but the functions/methods invoked at runtime do. The dependencies are realized at runtime after the code is initialized, hence the delayed name.
This is the simplest example:
// a.js
import { b } from "./b.js";
export const a = "aloha";
export const sayA = () => console.log("from a", b);
// b.js
import { a } from "./a.js";
export const b = "bye";
export const sayB = () => console.log("from b", a);
// index.js
import { sayA } from "./a.js";
import { sayB } from "./b.js";
sayA();
sayB();
Which results in the correct:
from a bye
from b aloha
Even though a
depends on b
and vice versa, this is not a bug because the modules don’t need each other’s top-level code to initialize.
The easiest way to understand this is to look at how esbuild
bundles the code (the CommonJS modules and ESM work similarly).
// b.js
var b = "bye";
var sayB = () => console.log("from b", a);
// a.js
var a = "aloha";
var sayA = () => console.log("from a", b);
// index.js
sayA();
sayB();
You see that by the time we define sayB
, the a
variable is undefined
, but that’s not a problem because by the time we run sayB
, the a
variable is already defined.
Prevention Is the Best Cure
Now you understand how circular dependencies work in JavaScript, and if you don’t use esbuild
, maybe you’ll even get some errors/warnings when you run the code. However, these warnings are only for immediate circular dependencies.
But even delayed circular dependencies are a code smell, making the code harder to reason about. Imagine thinking about TCP/IP protocol layers, but the network (lowest) layer would have a circular dependency on the application (highest) layer, say SSH.
And in production code, the circular dependency virtually always involves more than two modules (e.g., a
-> b
-> c
-> d
-> a
), so you won’t spot it during development or code review.
But cyclic dependencies can be found by static analysis. And that’s exactly what the import/no-cycle
eslint
rule does. I strongly recommend installing the eslint-plugin-import
node module to your ESLint and using the import/no-cycle
rule to prevent both immediate and delayed circular dependencies.
You can find all the code from this post in my viktomas/circular-dependencies-showcase project.