Debugging Circular Dependencies in Monorepos
A circular dependency is two or more packages that import each other directly or transitively, violating the Directed Acyclic Graph (DAG) that every workspace manager and bundler assumes. The result is a module that evaluates before its exports are defined — yielding undefined values, stack overflows, or a build that simply hangs. This guide shows how to reproduce the exact error, locate the cycle, and refactor it out permanently.
Exact symptoms and error strings
Match your pipeline or runtime logs against these signatures to confirm you're dealing with a cycle and not a missing dependency:
Error: Cannot find module '@repo/package-b'
RangeError: Maximum call stack size exceeded
Circular dependency detected: package-a -> package-b -> package-a
Cycle detected in dependency graph
The most deceptive symptom is silent: an imported value is undefined at module top level even though the source clearly exports it. That is a cycle resolving with a partially initialized module, not a typo.
TypeError: Cannot read properties of undefined (reading 'createClient')
Root cause
Workspace managers (pnpm, npm, Yarn) resolve local packages via symlinks in node_modules, so an import of @repo/package-b from @repo/package-a is a real module edge in the graph. When package-b imports back from package-a, the runtime must evaluate one before the other can finish — but neither can finish first.
CommonJS fails hard: require returns the partially populated module.exports of the in-progress module, so a destructured import is undefined and a function call on it throws Maximum call stack size exceeded. ESM is more forgiving thanks to live bindings — the binding is hoisted and filled in later — but any value accessed synchronously during initialization is still undefined. Neither format tolerates cycles reliably in production.
This is fundamentally a graph-shape problem, which is why the durable fix lives at the architecture level described in Cross-Package Dependency Management: enforce a strict DAG so a cycle can never form, rather than patching each one as it surfaces.
To see the failure concretely, picture two packages that each reach for the other at module top level:
// packages/a/src/index.ts
import { formatB } from '@repo/b';
export const labelA = 'A:' + formatB();
// packages/b/src/index.ts
import { labelA } from '@repo/a'; // <- closes the cycle
export const formatB = () => labelA.toUpperCase();
When Node evaluates @repo/a, it pauses to load @repo/b, which immediately reads labelA — but labelA has not been assigned yet, so under CommonJS formatB calls .toUpperCase() on undefined and throws, while under ESM labelA is a live binding that reads undefined at that instant. The values are correct only if nothing is accessed during initialization, which is too fragile to ship.
A circular dependency is when two or more packages depend on each other, directly or through a chain, so the dependency graph is no longer acyclic. This is corrosive in a monorepo because build order becomes undefined — if package A depends on B and B depends on A, neither can build first — and it defeats affected detection and boundary reasoning, which assume a directed acyclic graph. Cycles also cause subtle runtime bugs when a module is used before the cycle has finished initializing it.
Cycles are easy to introduce and hard to spot by reading code, because the loop can span several packages through a chain of imports nobody traces end to end. They often appear gradually — a convenient import here, a shared helper there — until the graph closes a loop. The failure they cause is frequently intermittent, showing up only under a particular build order or module-load sequence, which is why detecting them structurally rather than by symptom is what makes them tractable.
Resolution and config patch
Work through these steps in order. Detection comes first, because runtime errors usually point at the symptom, not the origin of the cycle.
-
Detect and map the cycle. Run static graph analysis to print the exact import chain:
# Universal static analysis across source files npx madge --circular src/ # Nx: open the interactive project graph npx nx graph # Turborepo: print the task dependency graph turbo run build --graph -
Identify the shared surface. From the chain (
a -> b -> a), find which package logically owns the shared types or stateless helpers. That is what moves out. -
Extract a leaf package. Move the shared interfaces, types, and stateless utilities into a dedicated leaf —
@repo/typesor@repo/shared-core— that has zero internal dependencies. Refactor both original packages to import only from the leaf, and delete the direct cross-imports between them. -
Fallback only if extraction is blocked. Where an architectural fix is temporarily impossible, defer the synchronous edge with a dynamic import so the cycle resolves at call time instead of init time:
// Synchronous — throws or returns undefined on a cycle import { helper } from '@repo/package-b'; // Deferred — the binding resolves when helper() is actually called const { helper } = await import('@repo/package-b'); -
Rebuild the resolution graph. Clear stale symlinks and reinstall so the manager rematerializes a clean tree:
rm -rf node_modules pnpm install --frozen-lockfile
Detect the cycle, then break it by extracting the shared code:
# Find circular dependencies across the workspace
npx madge --circular --extensions ts,tsx packages/
# Or use the task runner's own graph validation
pnpm turbo run build --graph
Break the cycle by moving the shared code both packages need into a third, lower-layer package that both depend on, converting A → B → A into A → shared and B → shared. Occasionally the right fix is to invert a dependency that points the wrong way, or to merge two packages that were never really separable.
Validation and debug commands
Confirm the cycle is gone before you push:
# Should print "No circular dependency found!"
npx madge --circular --extensions ts,tsx src/
# Re-trace any package that was involved in the cycle
pnpm why @repo/package-b
# Verify the symlink targets resolve to local source
ls -la node_modules/@repo/
If madge still reports a cycle after the refactor, a bundler misconfiguration may be cloning a package into two module instances that look mutually dependent. Check that external versus noExternal (Vite) or externals (Webpack) treat every @repo/* package consistently.
Prevention and CI guardrails
- Add an
import/no-cycleESLint rule so a cycle fails lint locally before it reaches CI:
{
"plugins": ["import"],
"rules": {
"import/no-cycle": ["error", { "maxDepth": 1 }]
}
}
-
Add a dedicated pre-merge check that fails fast on any graph violation:
{ "scripts": { "check:cycles": "madge --circular --extensions ts,tsx src/ || exit 1" } } -
Keep
tsconfig.jsoncompilerOptions.pathsaligned exactly with workspace aliases, and enablestrict: trueto surface the implicitanyvalues that often mask a broken export from a cycle. -
Enforce a strict downward-only dependency model — types and constants in a leaf, stateless helpers above that, feature packages on top — as described in Monorepo Architecture & Orchestration. Never allow sibling or upward edges.
-
Run a circular-dependency check (madge, or the task runner's graph validation) in CI.
-
Break cycles by extracting shared code into a lower-layer package both sides depend on.
-
Enforce module boundaries with tags/lint rules so illegal edges fail before they close a loop.
-
Keep the dependency graph acyclic — every downstream capability depends on it.
Breaking a cycle by extracting shared code
The fix for a circular dependency is almost always structural: extract the code both packages need into a third, lower-layer package that both depend on. If A imports from B and B imports from A, the shared pieces causing the mutual dependency move into a new shared package, converting the cycle A → B → A into two acyclic edges A → shared and B → shared. The graph is directed and acyclic again, so build order is well-defined and affected detection works.
Occasionally a different fix applies. If the cycle exists because an import points the wrong way — a lower-layer package reaching up into a higher-layer one — inverting that dependency removes the loop without a new package. If two packages are so intertwined that every attempt to separate them recreates the cycle, they may genuinely be one package that was split prematurely, and merging them is the honest answer. Whichever applies, the target is a directed acyclic graph, because that structure is what ordered builds, affected detection, and boundary enforcement all depend on. Treating a cycle as a design signal — usually that shared code wants its own home — rather than a nuisance to work around is what keeps the architecture clean as the monorepo grows.
Preventing cycles with boundary enforcement
Detecting cycles after they appear is useful, but preventing them from being introduced is better, and that is what module-boundary enforcement provides. By tagging each package by layer — a scope and a type — and configuring a lint rule that forbids illegal edges, an import that would create a cycle or violate the intended layering fails lint on the pull request that introduced it. A UI package importing a feature package, or a lower-layer package reaching up, is caught at the point of introduction rather than discovered later as an intermittent build failure.
This turns architectural intent into something the tooling guarantees. Instead of relying on reviewers to notice that an import closes a loop — which is hard to see across packages — the boundary rule enforces the acyclic structure mechanically. Running a circular-dependency check in CI as a backstop catches anything the boundary rules miss, so the two together keep the graph acyclic by construction. The payoff compounds: an acyclic graph keeps build order well-defined, affected detection accurate, and refactors local, so the effort spent enforcing boundaries pays back as the monorepo grows and the number of possible import paths — and therefore possible cycles — multiplies.
Frequently Asked Questions
How do I detect circular dependencies in a pnpm or npm workspace?
Run madge --circular src/ for a universal static scan, or use the workspace-native nx graph and turbo run build --graph. These parse import statements into a directed graph and highlight cycles before runtime, so you catch the origin rather than the downstream symptom.
Does ESM handle circular dependencies better than CommonJS?
Somewhat. ESM uses live bindings, so a partially initialized module can be referenced without an immediate crash — but a value accessed synchronously during initialization is still undefined. CommonJS fails immediately with Maximum call stack size exceeded or missing exports. Neither should be relied on to tolerate cycles in production.
Can TypeScript catch circular imports at compile time?
No. TypeScript compiles files independently and does not enforce import-graph topology. Use eslint-plugin-import/no-cycle or a bundler plugin to enforce a cycle-free architecture during development and in CI.
What folder structure prevents future cycles?
A strict hierarchy: a @repo/types leaf for shared types and constants with zero dependencies, utility packages for stateless helpers above it, and feature packages that depend only downward. Enforce the direction with the import/no-cycle lint rule so a regression fails the build.
How do I find circular dependencies in a monorepo?
Run a tool like madge --circular over your packages, or use the task runner's graph validation. Both surface the loop as a named chain of packages, turning an intermittent build failure into a concrete, locatable problem.
How do I break a circular dependency?
Extract the shared code both packages need into a third, lower-layer package both depend on, converting A → B → A into A → shared and B → shared. Occasionally the fix is inverting a wrong-way dependency or merging two packages that were split prematurely.
How do I stop cycles from being introduced?
Enforce module boundaries: tag packages by layer and add a lint rule that forbids illegal edges, so an import that would create a cycle fails on the PR that introduced it. Add a madge --circular CI check as a backstop.
Are all circular dependencies harmful?
Not always at runtime — some cycles resolve because modules are fully initialized before use — but they always make build order undefined and defeat affected detection, and they cause intermittent init-order bugs. Treat any cycle as a design problem to break, not a nuisance to tolerate.
Can a task runner detect cycles for me?
Yes — running the task runner's graph command (or a madge --circular check) surfaces a cycle as a named chain, and adding it to CI fails the build when a new import closes a loop, so cycles are caught at introduction rather than as an intermittent failure later.
Related
- Cross-Package Dependency Management — the DAG and
workspace:patterns that prevent cycles forming. - Monorepo Architecture & Orchestration — where dependency topology fits among caching and task orchestration.
- Workspace Symlinks vs Hard Links — how the symlinked
node_modulesthat resolves these edges is built. - ESM and CJS Interoperability — why live bindings change a cycle's behavior between module formats.