# 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.

Canonical URL: https://www.devobs.io/articles/qa-ge50-when-should-dynamic-import-break-a-javascript-module-cycle/
By: Nina Patel
Published: 2025-11-10T02:35:43.527Z
Updated: 2026-09-06T08:31:04.426Z
Section: Architecture

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](https://nodejs.org/api/modules.html), [Node.js ECMAScript modules](https://nodejs.org/api/esm.html)).

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"`.

```js
// 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:

```js
// 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:

```js
// 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)). 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)). 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.

## Source references

- <https://nodejs.org/api/modules.html>
- <https://nodejs.org/api/esm.html>
- <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import>
