Skip to content

ESLint Performance: Profiling and Optimizing Slow Rules

Measure ESLint cost before debating rules, profile slow plugins, use cache and concurrency with evidence, and keep CI from becoming a lint tax.

5 min read
ESLint
Performance
Linting
CI
Tooling
Monorepo

Framework field can wait for a coffee. Lint time cannot. When pnpm lint takes long enough that people skip it locally, the ruleset stops being governance and becomes tribal knowledge: "CI will catch it," until CI is also slow.

I already covered flat config migration and monorepo ESLint + Prettier setup. Measure first, then cut, cache, or parallelize.

Measure before you theorize

ESLint can TIMING-profile rule and file costs:

bash
# From a package root: TIMINGS=1 prints a rule timing table TIMING=1 pnpm exec eslint . --max-warnings=0

You want two numbers:

  1. Wall time for the command your CI actually runs (not a toy folder).
  2. Top rules by time from TIMING=1 (or TIMING=10 / TIMING=20 for the slowest N).

I paste both into the PR that "optimizes lint." Without them, we are rearranging config for vibes.

Illustrative timing shape (numbers will differ, do not cargo-cult thresholds):

text
Rule | Time (ms) | Relative ----------------------------------------|-----------|---------- @typescript-eslint/no-floating-promises | 1840 | 22% import/no-cycle | 1510 | 18% react-hooks/exhaustive-deps | 620 | 7% ...

Now you have a hit list. Everything else is commentary.

What is usually slow (and why)

Patterns I see repeatedly in TS/React monorepos:

CulpritWhy it burns timeFirst move
import/no-cycleGraph walks across packagesScope to apps, or replace with a dedicated circular-dep check in CI
Heavy @typescript-eslint type-aware rulesNeeds type info; parserServices is expensiveLimit project/projectService to packages that need it
import/no-unresolved without correct resolverRe-resolves foreverFix resolver once; do not disable blindly
Huge ignores mistakesLinting dist, .next, generatedFix ignores, free win
Running ESLint on the whole monorepo serially from one rootNo package boundaryPer-package scripts + --cache

Type-aware linting is valuable. It is also the first place I look when someone says "ESLint got 3× slower after we turned on recommended-type-checked."

Cache and parallel: with honesty

json
// package.json scripts, shape { "scripts": { "lint": "eslint . --cache --cache-location node_modules/.cache/eslint", "lint:ci": "eslint . --cache --cache-location .eslintcache --max-warnings=0" } }

Cache helps when the file set and config are stable between runs. It does not help a cold CI job unless you restore the cache artifact between pipelines. Local --cache without CI cache restore is a laptop-only win - still worth it for developers; just do not claim CI got faster.

Parallelism depends on how you structure the monorepo:

bash
# pnpm: lint packages concurrently instead of one mega eslint pnpm -r --parallel --aggregate-output run lint

Each package should own a focused eslint invocation. One process walking the entire repo with a single type-aware project pointing at a giant tsconfig is how you invent a heat lamp.

Flat config reminder from earlier in the series: put ignores first, keep files globs tight, and do not apply type-aware configs to *.js config files that do not need them.

A profiling workflow I trust

  1. Run the exact CI lint command locally; record wall time.
  2. TIMING=1 (or top-N) on the worst package.
  3. For each top rule: keep / narrow / replace / drop.
    • Keep: value > cost (document why).
    • Narrow: files glob, or disable for tests/stories.
    • Replace: e.g. cycle detection via a dedicated tool in CI weekly, not every file save.
    • Drop: rule that never fired a useful finding in six months.
  4. Re-measure. Commit the timing table in the PR description.
  5. Only then tune worker count / cache keys.

Skipping to step 5 is how teams buy bigger CI runners for a misconfigured parserOptions.project.

Narrowing type-aware rules without lying

js
// eslint.config.js, illustrative import tseslint from 'typescript-eslint'; export default tseslint.config( { ignores: ['**/dist/**', '**/.next/**', '**/coverage/**'] }, // Fast baseline everywhere ...tseslint.configs.recommended, // Expensive rules only where TS projects exist { files: ['apps/web/src/**/*.{ts,tsx}', 'packages/ui/src/**/*.{ts,tsx}'], extends: [...tseslint.configs.recommendedTypeChecked], languageOptions: { parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, }, }, }, );

projectService (ESLint TS tooling in the mid-2020s) is often kinder than a hand-maintained project: true glob farm, still profile it. Do not enable type-aware configs repo-wide "for consistency." Consistency with a ten-minute lint is not a virtue.

Failure modes

Disabling slow rules silently. Governance died; say so in CODEOWNERS or the lint ADR.

Caching broken outputs. If config changes, bust the cache key. Stale cache plus "CI is green" is a special kind of betrayal.

Linting generated files. GraphQL/OpenAPI output does not need your React hooks rules.

Pre-commit running full-repo lint. Use staged-file linting (covered earlier); save full lint for CI / pre-push if you must.

Decision rule

text
Lint feels slow? → Measure wall + TIMING. No ticket without numbers. Top cost is type-aware? → Narrow files / packages before dropping safety. Top cost is import graph? → Scope or move off the critical path. Only then → Cache restore in CI + parallel package lint.

Takeaway

ESLint performance is a measurement problem before it is a rules debate. Profile with TIMING, narrow expensive type-aware and graph rules to the packages that justify them, then add cache and parallelism, measure first, do not rely on hearsay.