All posts
javascriptmodules

Fixing Circular Dependency Errors in JavaScript and TypeScript

Why circular dependency warnings and errors happen between JS/TS modules, and practical patterns to break the cycle.

SR

Suhail Roushan

August 6, 2026

·
5 min read
·
0 views

A circular dependency happens when module A imports from module B, and module B (directly or through a chain of other modules) imports back from module A — and depending on the module system and exactly what's being imported, this can silently produce undefined values instead of an outright crash, which makes it a particularly sneaky class of bug.

This warning or error means your module graph has a cycle — two or more modules depend on each other, directly or transitively — and because JavaScript modules execute top-to-bottom on first import, whichever module in the cycle finishes evaluating last may see incomplete, not-yet-initialized exports from the other.

Why This Error Happens

When module A imports module B, and B is still in the middle of being evaluated because it imported A first (which is still evaluating B — the cycle), the module system has to break the loop somewhere. Depending on whether you're using ES modules or CommonJS, and depending on how the value is used (referenced at import time vs. referenced later inside a function), you either get a genuinely broken undefined value or, in ESM's case, sometimes a working live binding — reliability being unpredictable is exactly the problem.

Reproducing the Error

A minimal cycle:

// userService.ts
import { logActivity } from "./activityLogger";

export function createUser(name: string) {
  logActivity(`Created user ${name}`);
  return { name };
}

// activityLogger.ts
import { createUser } from "./userService"; // cycle back to userService

export function logActivity(message: string) {
  console.log(message);
}

export function logNewAdmin(name: string) {
  const user = createUser(name); // may be undefined depending on evaluation order
}

Depending on which module loads first, createUser referenced inside activityLogger.ts can be undefined at the moment it's needed, throwing a "not a function" error that only appears based on import order.

Core Concepts Behind This Error

Circular dependencies almost always signal a design problem, not just a technical inconvenience — two modules needing each other directly usually means responsibilities are split in a way that doesn't reflect a clean dependency direction, and the actual fix is often restructuring rather than a technical workaround.

ESM's live bindings behave differently from CommonJS's value copying in a cycle — ES modules can sometimes resolve correctly because imports are live references updated once the source module finishes evaluating, while CommonJS's require() returns a snapshot at import time that may be incomplete — meaning the exact same logical cycle can behave differently depending on module format.

Bundler and build tool warnings about circular dependencies are worth taking seriously even when the code "works" — a cycle that happens to function correctly today is fragile to reordering, refactoring, or a build tool change, since its correctness depends on evaluation order rather than the code's actual logic.

A shared, lower-level module extracted from both sides of a cycle is the most common structural fix — if A and B both need something from each other, that shared thing usually belongs in a new module C that both A and B depend on, restoring a clean, acyclic dependency direction.

Fixing Circular Dependencies

Fix 1: Extract the shared logic into a separate module that both original modules depend on, breaking the cycle by restructuring rather than working around it:

// activityLog.ts (new, shared)
export function logActivity(message: string) {
  console.log(message);
}

// userService.ts
import { logActivity } from "./activityLog";
export function createUser(name: string) {
  logActivity(`Created user ${name}`);
  return { name };
}

// adminService.ts
import { logActivity } from "./activityLog";
import { createUser } from "./userService";
export function logNewAdmin(name: string) {
  const user = createUser(name);
  logActivity(`Promoted ${user.name} to admin`);
}

Fix 2: Move the import inside the function body (lazy/deferred import) when restructuring isn't immediately practical, deferring resolution until the function actually runs, by which point both modules have finished their initial evaluation:

export function logNewAdmin(name: string) {
  const { createUser } = require("./userService"); // resolved at call time, not import time
  const user = createUser(name);
}

Fix 3: Use dependency injection instead of direct cross-module imports, passing the needed function or object in as a parameter rather than importing it directly, which removes the compile-time dependency edge entirely.

Does a Circular Dependency Always Cause a Runtime Bug?

Not always — many cycles happen to work correctly because of how the specific values are used (functions referenced only inside other functions' bodies, not at module top-level, tend to survive cycles fine since they're not evaluated until called). But "happens to work" is fragile: any change to import order, bundler behavior, or how the value is referenced can break it, so treat a detected cycle as worth fixing structurally even if it isn't currently causing an observable bug.

Preventing Circular Dependencies in Production

Design module dependencies to flow in one direction where possible — lower-level, shared modules that higher-level modules depend on, never the reverse — and extract genuinely shared logic into its own module rather than letting two modules depend on each other directly. Configure your linter or bundler to flag circular dependencies explicitly (ESLint's import/no-cycle, or bundler-specific warnings) so cycles are caught during development rather than discovered as a confusing runtime bug later.

If your bundler warns about a circular dependency, treat it as a structural issue worth fixing at the source — extracting shared logic into its own module almost always produces a cleaner, more maintainable result than a lazy-import workaround.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch