DEV Community

Cover image for no-cycle Is Commented Out in Your ESLint Config. Here Is the Two-Minute Swap That Turns It Back On.
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

no-cycle Is Commented Out in Your ESLint Config. Here Is the Two-Minute Swap That Turns It Back On.

Somewhere in your eslint.config.js there is a commented-out line that says import/no-cycle. Nobody decided circular dependencies were fine. Somebody decided a red pipeline was blocking six people. This is the install that lets you switch it back on — and the whole path is about two minutes.

eslint-plugin-import is the incumbent for good reasons: 51,330,539 weekly downloads (npm downloads API, week 2026-07-12→18, fetched 2026-07-19) and a rule set the entire ecosystem writes its configs against. eslint-plugin-import-next is a drop-in for that rule set — same rule names, same semantics, different graph algorithms underneath.

Below: the install, the output you will actually see, the one migration gotcha that costs people ten minutes, and the command that lets you check my performance claims instead of believing them.

(I wrote import-next. That makes me the wrong person to trust on "it's faster" and the right person to hand you the command that re-runs the benchmark on your own hardware.)


Install (60 seconds)

npm install --save-dev eslint-plugin-import-next
Enter fullscreen mode Exit fullscreen mode

Flat config (eslint.config.js):

import { configs } from "eslint-plugin-import-next";

export default [
  configs.recommended, // 16 rules — 7 error, 9 warn
];
Enter fullscreen mode Exit fullscreen mode

Run it:

npx eslint .
Enter fullscreen mode Exit fullscreen mode

That is the entire setup. No resolver package, no settings block — TypeScript path aliases and extensionless imports resolve out of the box (there is a verified example further down).

One detail worth pinning: the named export is configs. import { configs } from "eslint-plugin-import-next" works in every loader; a default import is fine in Node's own ESM loader but goes through interop rules if a bundler or esModuleInterop is in the path. Destructure and skip the question.

What you'll actually see

Two files that import each other — the smallest possible cycle:

// src/a.js
import { b } from "./b.js";
export const a = () => b();

// src/b.js
import { a } from "./a.js";
export const b = () => a();
Enter fullscreen mode Exit fullscreen mode
src/a.js
  1:1  error    🏗️ CWE-407 OWASP:A06-Insecure CVSS:5.3 | Circular dependency detected | CRITICAL
   Fix: Split a into .core and .extended | https://en.wikipedia.org/wiki/Circular_dependency   import-next/no-cycle
  1:1  warning  📚 Require a newline after the last import statement | LOW
   Fix: Add a newline after the import block                                   import-next/newline-after-import

(src/b.js reports the same pair)

✖ 4 problems (2 errors, 2 warnings)
  0 errors and 2 warnings potentially fixable with the `--fix` option.
Enter fullscreen mode Exit fullscreen mode

Real output — eslint-plugin-import-next@2.3.8 on eslint@9.39.5, Node v24.12.0, run 2026-07-28. Doc URLs trimmed to fit; everything else is verbatim. Environments belong next to numbers, including small ones.

Every finding carries a CWE id, an OWASP Top 10 category, and a CVSS base score, so a lint finding survives the trip into a security dashboard without a human re-typing it. What the cycle itself costs you at runtime — a partial view of a module's exports, silently undefined in CommonJS and a hard ReferenceError in native ESM — has its own explainer; the short version is that it surfaces far from the two files that caused it.

Now look at that line again: it prints CRITICAL next to a base score of 5.3, and 5.3 is squarely the Medium band. That is severity-word drift, and I know the shape of it because I audited 203 of my own security rules and found 16% of them mislabeling their own scores, in both directions. So: gate CI on the numeric score, never on the word. Config advice from a linter that just failed its own spelling test, which is the only kind I'm qualified to give here.


Migration from eslint-plugin-import: two paths

Path A — swap the presets

npm uninstall eslint-plugin-import
npm install --save-dev eslint-plugin-import-next
Enter fullscreen mode Exit fullscreen mode
// Before
import importPlugin from "eslint-plugin-import";
export default [importPlugin.configs.recommended];

// After
import { configs } from "eslint-plugin-import-next";
export default [configs.recommended];
Enter fullscreen mode Exit fullscreen mode

The gotcha nobody warns you about. The plugin registers under the namespace import-next, so every hand-written entry you have — "import/no-cycle": ["error", { maxDepth: 3 }] — stops resolving the moment the old package is gone. ESLint tells you the rule doesn't exist, you assume the migration failed, and you roll back. It didn't fail; the prefix moved.

Path B — zero-diff, keep every import/* rule name

If your config has more than a couple of hand-tuned entries, register the new plugin under the old namespace and change nothing else:

import importNext from "eslint-plugin-import-next";

export default [
  {
    plugins: { import: importNext },
    rules: {
      "import/no-cycle": "error", // every existing import/* entry keeps working
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Ran it: findings come back under the rule id import/no-cycle, not import-next/no-cycle. Your rule entries, your eslint-disable comments, and any CI script grepping for import/ all keep working untouched.

One package you can usually drop: eslint-import-resolver-typescript, if it was only there for import resolution. Resolution is built in. Checked against a tsconfig.json with paths: { "@app/*": ["src/*"] } and no-unresolved set to error: both import { helper } from "./helper" (extensionless .ts) and import { helper } from "@app/helper" come back clean with no settings block at all — while a genuinely missing module still errors. Silence because it's satisfied, not silence because it gave up. (Same environment as the run above; both migration paths were exercised there before this section was written.)


The 12 presets (v2.3.8)

import { configs } from "eslint-plugin-import-next";

export default [
  configs.recommended, // start here
  // configs.flagship,  // just no-cycle — the CI-gate subset
];
Enter fullscreen mode Exit fullscreen mode
Preset Rules What it's for
flagship 1 no-cycle alone — the minimum viable CI gate
recommended 16 The default. 7 error, 9 warn
strict 42 Everything, all as errors
typescript 11 TS-tuned (named off — the compiler covers it)
module-resolution 9 Broken paths and unresolvable imports only
import-style 7 Ordering and formatting only
esm 5 ES modules enforced, CommonJS out
architecture 7 Layer and domain boundaries
performance 6 Barrel files and tree-shaking
enterprise 5 Team boundaries, legacy import tracking
errors 5 Mirrors eslint-plugin-import's errors preset
warnings 3 Mirrors eslint-plugin-import's warnings preset

recommended deliberately leaves nine rules at warn — ordering, newline-after-import, extraneous dependencies. A first run on a codebase that has been running without them should not be a wall of red. Move to strict once the errors are at zero.

The rule set (56 ids, 55 rules, v2.3.8)

Category Rules Examples
Module resolution 11 no-unresolved, named, default, no-duplicates
Import style 12 order, first, newline-after-import
Export style 9 export, no-mutable-exports, no-default-export
Dependency boundaries 6 no-cycle, no-restricted-paths
Module system 5 no-commonjs, no-amd, unambiguous
Bundle optimization 5 no-barrel-file, prefer-direct-import
Dependency management 4 no-extraneous-dependencies, max-dependencies
Enterprise governance 4 enforce-team-boundaries, no-legacy-imports

Fifty-six ids, fifty-five implementations: order and enforce-import-order are the same module under two names. That duplicate is load-bearing, not sloppiness — it is what lets a config written against import/order keep working through Path B.


"Is it actually faster?" — the numbers, and how to re-run them

Version-stamped, because a speedup with no versions on it is a marketing claim: eslint-plugin-import@2.32.0 vs eslint-plugin-import-next@2.3.3, synthetic corpus, n=3, cache cleared between runs, measured 2026-01-02.

Configuration 1,000 files 5,000 files
Core rules (9) 1.6x 3.3x
Recommended preset 1.4x 3.0x
no-cycle only 25.7x 54.8x (148.59s ± 31.13s → 2.71s ± 0.01s)

54.8x at 5,000 files is the measured maximum. This page used to be headlined "100x"; that figure is a projection at 10,000 files, not a clock reading — I stopped running the old plugin at 5K because a single iteration was already around two and a half minutes. And the run is from January against 2.3.3, while the version you just installed is 2.3.8. Old measurements do not quietly age into new ones, which is why the claim in our own registry carries a "re-verify after 90 days" flag rather than a permanent checkmark.

So don't take the table on trust — it is reproducible from a public repo in about five minutes:

git clone https://github.com/ofri-peretz/eslint-benchmark-suite.git
cd eslint-benchmark-suite && npm install
npm run generate:import
Enter fullscreen mode Exit fullscreen mode

The full method, the error bands, and an honest account of which part of the gap I cannot explain are in the benchmark write-up.

What it sees, and what it doesn't

  • The import graph, not the runtime. no-cycle tells you two modules form a loop. Whether that loop actually breaks depends on initialization order and which module loads first — a cycle can sit in a codebase for years without failing.
  • It is a linter, not a prover. Both plugins are heuristics over a static graph, which means both can produce false positives and false negatives — and where a linter sits relative to SAST and full static analysis is worth ten minutes of your time before you write the CI gate.
  • Including mine. My no-cycle once reported 0 cycles on a 14,556-file Next.js repository that had them, because a depth-truncated traversal cached files as acyclic that it had never finished exploring. Five lines fixed it; the full forensic is here. A suspiciously clean zero is the result you check hardest — the metric that catches that class of bug is recall, and the unit tests were all green throughout.

Links

Run configs.flagship against the repo where no-cycle is commented out — one rule, one command, no config debate. If it comes back clean, you have lost thirty seconds. If it comes back with a number, you have just recovered a check the team turned off under deadline pressure and never got to turn back on. Then read Payload CMS Has 508 Circular Dependencies. Next.js Has 17. for why that number is almost never zero in a codebase over a few thousand files, and why AI-assisted development is making it grow faster.

What is the count in your oldest service — and how long ago was the rule switched off? I collect these.

📦 npm install --save-dev eslint-plugin-import-next — 55 rules, one dev dependency, and no-cycle back on.


Part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz


I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)