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:
- Wall time for the command your CI actually runs (not a toy folder).
- Top rules by time from
TIMING=1(orTIMING=10/TIMING=20for 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):
textRule | 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:
| Culprit | Why it burns time | First move |
|---|---|---|
import/no-cycle | Graph walks across packages | Scope to apps, or replace with a dedicated circular-dep check in CI |
Heavy @typescript-eslint type-aware rules | Needs type info; parserServices is expensive | Limit project/projectService to packages that need it |
import/no-unresolved without correct resolver | Re-resolves forever | Fix resolver once; do not disable blindly |
Huge ignores mistakes | Linting dist, .next, generated | Fix ignores, free win |
| Running ESLint on the whole monorepo serially from one root | No package boundary | Per-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
- Run the exact CI lint command locally; record wall time.
TIMING=1(or top-N) on the worst package.- For each top rule: keep / narrow / replace / drop.
- Keep: value > cost (document why).
- Narrow:
filesglob, 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.
- Re-measure. Commit the timing table in the PR description.
- 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
textLint 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.