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.

From source graph to named cycle edges A pipeline where a detector reads the module graph, finds a strongly connected component, and prints the closing edge chain. Cycle Detection Pipeline Parse graph imports / depends_on Find SCC strongly connected Print edges A → B → A The printed edge chain is your minimal fix list.
madge, pydeps, and Compose all reduce to the same three-stage pipeline; only the graph source differs.

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.

How a JavaScript import cycle yields undefined Four ordered stages showing module A starting, requiring B, B re-requiring the not-yet-finished A, and receiving an empty binding. Partial Init Timeline 1 — order.ts begins evaluating 2 — it imports user.ts 3 — user.ts re-imports order.ts 4 — binding is still undefined
The crash lands at step 4, but the defect is the back-edge created at step 3.

Resolution

  1. Identify the exact edges in the cycle from the detector output.
  2. Extract the shared types/constants both ends need into a third, dependency-free module.
  3. Re-point both ends at the new module, removing the back-edge.
  4. For Compose, replace the back-edge depends_on with 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.

Choosing how to break the edge A decision on whether the shared piece is a value or a type, leading to leaf extraction or a type-only import. Which Fix Applies Is the shared piece a runtime value? Yes move it to a leaf module No (type only) use import type
A runtime value must physically move to a leaf; a pure type can be erased with a type-only import.

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

  1. Add madge --circular (JS) or lint-imports (Python) as a pre-commit hook and a CI gate so a new cycle fails the pull request.
  2. Run cycle detection inside make doctor so contributors catch loops locally — see building an onboarding health-check script.
  3. 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.

Cycle scan time by tool Bar chart comparing scan seconds for madge, pydeps, and docker compose config on a mid-sized repository. Scan Time (seconds) pydeps 4.1s madge 1.8s compose config 0.5s
All three detectors finish in seconds, so gating every commit adds negligible friction.

macOS (Docker Desktop): madge graph rendering needs Graphviz (brew install graphviz); the --circular text check works without it. WSL2: run detectors from the Linux filesystem — madge and pydeps walk thousands of files and are an order of magnitude slower over /mnt/c. Apple Silicon (ARM64): install Graphviz/pydeps from a native arm64 toolchain to avoid exec format error when 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.