SOFTWARE / SYSTEMS / AIEngineering news. Technical depth.
Architecture / 4 MIN READ

When should dynamic import() break a JavaScript module cycle?

Use dynamic import() to break a module cycle only when the delayed dependency is a real async boundary.

Use import() to break a module cycle only when the delayed edge is supposed to be asynchronous anyway: optional features, user-triggered paths, plugins, or startup work that can happen later. If both modules still need each other during top-level initialization, import() does not fix the design. It only turns a deterministic cycle bug into an async one with promises, loading failures, and race conditions.

Why do module cycles fail differently in ESM and CommonJS?

In Node.js, require() and import() do not use the same loader. Node documents that require() uses the CommonJS loader while import() uses the ECMAScript module loader, and that import() is asynchronous in Node as well as browsers (Node.js CommonJS modules, Node.js ECMAScript modules).

That matters because static ESM cycles expose initialization-order problems through live bindings. A binding exists across the cycle, but reading it during module evaluation can still happen before initialization. CommonJS fails differently: a module can receive another module’s partially initialized exports object during a cycle. Different symptom, same root cause: two modules are doing mutually dependent work too early.

What does a broken ESM cycle look like?

Prerequisite: run as ESM in Node.js with .mjs files or a package using "type": "module".

// a.mjs
import { configB } from './b.mjs';
export const configA = { retries: configB.retries + 1 };

// b.mjs
import { configA } from './a.mjs';
export const configB = { retries: configA.retries + 1 };

This is not a loading problem. It is a graph problem. Each module needs the other module’s fully initialized value during evaluation.

A sound fix is to move the shared seed into a third module:

// base.mjs
export const defaultRetries = 2;

// a.mjs
import { defaultRetries } from './base.mjs';
export const configA = { retries: defaultRetries + 1 };

// b.mjs
import { defaultRetries } from './base.mjs';
export const configB = { retries: defaultRetries + 2 };

Now consider a case where lazy loading is actually correct:

// dashboard.mjs
export async function openReports(user) {
  const { renderReports } = await import('./reports.mjs');
  return renderReports(user);
}

Here, reports are not required to initialize the dashboard. The async boundary is real, so import() is a clean refactor.

When is dynamic import() the right boundary?

MDN describes import() as loading a module “asynchronously and dynamically,” evaluated only when needed, and advises preferring static imports for initial dependencies (MDN import() reference). That leads to a practical rule: use import() when the caller can already tolerate waiting.

Good fits include optional UI panels, plugin systems, environment-specific adapters, or feature code behind an explicit user action. In browser-oriented toolchains, that often creates a separate chunk, which is beneficial if you wanted deferred download anyway. In Node, it can cleanly separate startup from later capability loading.

When does import() just hide the bug?

If both sides still call each other during startup, adding await import() inside init() is usually camouflage. You have not created an ordering boundary; you have only postponed the same dependency until promise resolution. MDN also notes that import() returns a promise and rejects if fetching, loading, or evaluation fails, and “never synchronously throws an error” (MDN import() reference). That means your API, tests, and failure handling all become asynchronous.

Use this checklist:

  • Extract shared state or types into a third module.
  • Invert control if one side only needs a callback or interface.
  • Move work from module top level into function call time if no async boundary is needed.
  • Use import() only when callers can await it intentionally.
  • Add tests for rejected imports and parallel calls.
  • Verify that bundler chunking matches your deployment goal.

Follow-up Q&A

Should I use import() in CommonJS to fix a cycle?
Only if the dependency should become async. Changing loaders does not repair a bad dependency graph.

Does top-level await solve cyclic initialization?
No. It can express async startup, but it does not remove mutual top-level dependence by itself.

Reviewed: 2026-09-05.

SOURCES & REVIEW

Sources are linked throughout this guide. Product capabilities can change; consult the linked documentation for your deployment.

Read our editorial approach ↗