Detecting Circular Dependencies in Local Builds
A local build hangs, a module imports as undefined, or docker compose up reports a dependency loop — all symptoms of a circular dependency, the failure mode that dependency tree visualization exists to surface. This guide reproduces the loop with a cycle detector, explains why each toolchain reacts the way it does, and walks through breaking the cycle so the build is deterministic again — the same fix whether the graph is JavaScript modules, Python imports, or Compose services.
Diagnostic
A circular dependency rarely announces itself. In JavaScript it shows up as a value that is undefined at import time but populated later; in Python as an ImportError that only fires on a particular entry point; in Docker Compose as a stack that silently refuses to start. Before you can fix it you need a tool that walks the graph and names the participating edges. Run a cycle detector against the relevant graph — module imports for JS/Python, or service ordering for Compose.
#!/usr/bin/env bash
set -euo pipefail
# JavaScript / TypeScript module graph
npx madge --circular --extensions ts,tsx,js src/ || true
# Python import graph
pydeps --show-cycles --no-output app/ || true
# Compose service ordering
docker compose config >/dev/null
madge builds the import graph by parsing every module's import/require statements and reports strongly connected components; pydeps does the equivalent for a Python package and marks back-edges; docker compose config resolves and validates the merged Compose file, and a depends_on loop makes that resolution fail. Run all three even if you think only one applies — a monorepo with a Node front end, a Python service, and a Compose stack can carry a cycle in any layer, and the one you ignore is usually the one blocking the build.
Expected BAD output — each tool names the participants in the loop:
✖ Found 1 circular dependency!
1) src/order.ts > src/user.ts > src/order.ts
Cycle found: app.order -> app.user -> app.order
service "app" depends on itself: app -> worker -> app
The arrow chains are the whole diagnosis: read them left to right and the last hop always closes back onto the first node. Copy that chain somewhere — it is the exact list of edges you will re-point in the Resolution step, and it tells you the minimum set of modules to touch.
Root cause
A circular dependency exists when module or service A transitively requires B and B transitively requires A, so there is no valid order in which to load or start them. In JavaScript, the runtime resolves the cycle by handing one module a partially initialized export — usually undefined — which surfaces far from the import as a null-reference crash. The module system does not error; it simply returns whatever bindings exist at the moment the second module is reached, and if that module has not finished evaluating, the binding is empty. In Python, a mid-import cycle raises ImportError: cannot import name because the target name is not yet bound in the partially executed module. In Docker Compose, depends_on describes a startup order, and a cycle makes that order unsatisfiable, so Compose refuses to start the stack rather than pick an arbitrary sequence.
The unifying idea is topological order. A build tool needs to arrange nodes so that everything a node needs is ready before the node itself runs — that arrangement only exists for a directed acyclic graph. Add one back-edge and the graph is no longer acyclic, the topological sort has no solution, and each tool degrades in its own way: JavaScript ships a half-built export, Python throws, Compose aborts. That is why the fix is always structural rather than a flag or a retry — you are removing the edge that makes the sort impossible, not persuading the tool to tolerate it.
Resolution
- Identify the exact edges in the cycle from the detector output.
- Extract the shared types/constants both ends need into a third, dependency-free module.
- Re-point both ends at the new module, removing the back-edge.
- For Compose, replace the back-edge
depends_onwith a runtime healthcheck/retry instead of a startup ordering.
The pattern behind all four steps is the same: find the one thing both nodes reach for, move it to a leaf that depends on nothing, and let each node depend on the leaf instead of on each other. A leaf module has no outgoing edges, so it can never participate in a cycle. Break a JS/TS cycle by hoisting the shared contract:
// src/types.ts — leaf module, imports nothing from order/user
export interface OrderRef { id: string; userId: string; }
// src/order.ts
import type { OrderRef } from './types'; // was: import { User } from './user'
export function makeOrder(ref: OrderRef) { return { ...ref, status: 'new' }; }
// src/user.ts
import type { OrderRef } from './types'; // both now point at the leaf, cycle gone
export function ordersFor(userId: string): OrderRef[] { return []; }
Note the import type keyword — for cycles that only exchange TypeScript types (not runtime values), marking the import as type-only lets the compiler erase it entirely, so the edge disappears from the emitted JavaScript even before you refactor. That is a fast interim fix when the shared piece is purely structural. When the shared piece is a runtime value, you must physically move it to the leaf module as shown above; a type-only annotation on a value import will fail at run time.
Enforce the boundary in Python with import-linter instead of relying on convention:
# .importlinter
[importlinter]
root_package = app
[importlinter:contract:no-cycles]
name = No circular imports
type = independence
modules =
app.order
app.user
The independence contract asserts that the listed modules may not import each other in either direction, which is stricter than merely forbidding a cycle — it also stops the next cycle before it forms. Where two modules genuinely must share code, apply the same leaf extraction: create app/refs.py with the shared dataclasses and have both app.order and app.user import from it. For a Compose depends_on cycle, break it by making one side tolerate the other being absent at start, gated on health rather than order:
# docker-compose.yml — worker no longer blocks on app; it retries at runtime
services:
app:
build: ./app
depends_on:
worker:
condition: service_started
worker:
build: ./worker
# was: depends_on: [app] -> cycle. Worker reconnects to app via retry loop.
restart: on-failure
The service-level fix trades a compile-time guarantee (startup order) for a runtime one (reconnection with backoff), which is almost always the correct trade for a bidirectional service relationship — two services that each need the other running cannot both start first, so one of them has to be resilient to the other's absence. If both truly must be up before either serves traffic, that is a sign the two services should be one, or that a third coordinator should own the handshake. For the deeper startup-order mechanics, see fixing service startup-order and healthcheck races.
Expected output
After breaking the cycles, every detector reports a clean graph:
$ npx madge --circular --extensions ts,tsx,js src/
✔ No circular dependency found!
$ lint-imports
Contracts: 1 kept, 0 broken.
$ docker compose up -d
[+] Running 2/2
✔ Container app-worker-1 Started
✔ Container app-app-1 Started
A clean run from all three tools means the graph now has a valid topological order, so every build after this one loads modules and starts services in a deterministic sequence. The undefined import and the ImportError are gone because the binding each end depends on is fully evaluated before it is read, and Compose starts because the ordering constraint is now satisfiable.
Confirm the fix survives a cold start rather than trusting a warm cache: delete the module cache and rebuild from scratch (rm -rf node_modules/.cache dist && npm run build, or docker compose build --no-cache). A cycle that a warm incremental build tolerated will reappear on a clean build the moment evaluation order shifts, and a cold run is exactly what a new contributor or a CI runner performs on first clone — the environment where these failures are most expensive and hardest to reproduce after the fact.
Prevention
- Add
madge --circular(JS) orlint-imports(Python) as a pre-commit hook and a CI gate so a new cycle fails the pull request. - Run cycle detection inside
make doctorso contributors catch loops locally — see building an onboarding health-check script. - Keep the service graph visible with mapping microservice dependencies for local dev so back-edges are obvious before they merge.
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: no-circular-imports
name: Detect circular dependencies
entry: npx madge --circular --extensions ts,tsx,js src/
language: system
pass_filenames: false
The economics strongly favour catching cycles at the boundary. A cycle caught by a pre-commit hook costs the author a few seconds; the same cycle caught in CI costs a round trip and a re-review; the same cycle that reaches main and ships a partially initialized export costs a production incident whose stack trace points nowhere near the import that caused it. The detectors run in well under the time budget of a pre-commit hook, so there is no reason to defer them. The chart below shows a typical scan on a mid-sized repository — fast enough to gate every commit without friction.
macOS (Docker Desktop):
madgegraph rendering needs Graphviz (brew install graphviz); the--circulartext check works without it. WSL2: run detectors from the Linux filesystem —madgeandpydepswalk thousands of files and are an order of magnitude slower over/mnt/c. Apple Silicon (ARM64): install Graphviz/pydepsfrom a native arm64 toolchain to avoidexec format errorwhen generating dependency images.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- src/ app/ docker-compose.yml # revert the extraction and depends_on edits
If the refactor introduces a regression — for example the leaf module accidentally imported something back from order.ts and reintroduced the loop — revert the working tree and re-run the detectors on the clean checkout to confirm the baseline, then re-apply the extraction one edge at a time. Because the fix is purely structural, the rollback is a plain git checkout; no data, migrations, or container volumes are involved.
Frequently Asked Questions
Why does my JavaScript import work sometimes and return undefined other times?
Because the value you get depends on module evaluation order. When two modules form a cycle, whichever one the runtime reaches second receives the first module's exports as they exist mid-evaluation — often before the binding you want is assigned. Change the entry point, the bundler, or the file order and the "second" module changes, so the same import is populated in one build and empty in another. The reliable fix is to remove the cycle, not to reorder imports.
Does import type in TypeScript actually break the cycle?
For cycles that exchange only types, yes. import type is erased by the compiler, so no require/import edge exists in the emitted JavaScript and the runtime graph is acyclic. It does nothing for cycles that pass runtime values — a class, a function, or a constant that both modules call. For those you must physically move the shared value into a leaf module that neither end imports back from.
Can I just add a depends_on healthcheck to fix a Compose cycle?
A healthcheck helps only after you remove the back-edge. Compose refuses to start a stack whose depends_on graph has a cycle, regardless of the condition type, because the startup order is unsatisfiable. Drop the reverse depends_on and make that service resilient to its peer being absent at boot (retry with backoff, restart: on-failure), then use a healthcheck to gate the forward dependency.
Will madge --circular catch cycles across dynamic import() calls?
Partially. madge parses static import/require and, with the right options, dynamic import() where the specifier is a string literal. It cannot resolve a fully dynamic specifier computed at run time, so a cycle hidden behind import(pathVariable) will slip past static analysis. Pair the static scan with an integration test that exercises the real entry points to catch those runtime-only loops.