Finding the ESLint Rules That Slow Down Your Linting
Find slow ESLint rules with TIMING=1 and Config Inspector. Compare type-aware rules, identify costly plugins, and tune your config to speed up linting.
When ESLint is slow, the cause is usually a small number of rules: type-aware typescript-eslint rules, eslint-plugin-prettier, @stylistic/indent, or cross-file eslint-plugin-import rules such as import/no-cycle. You can find them with TIMING=1 and ESLint Config Inspector’s stats panel.
A slow lint run gets expensive quickly. The pre-commit hook takes long enough that people start committing with --no-verify, and after that the lint config no longer protects anything.
This guide covers how to measure the cost of each rule, which rules usually turn out to be the problem, and what to change in eslint.config.js for each one.
Key Takeaways
- With type-aware linting, a full ESLint run should take about as long as
tscon the same project. A run that takes much longer points to a specific rule or config problem. TIMING=1 npx eslint .prints the slowest rules. The first type-aware rule almost always looks slowest because TypeScript caches type information, so test suspects one at a time.- ESLint Config Inspector 3.4.0 added a
--statsflag and a stats panel that runs ESLint timing when you ask for it. This is a separate flag from ESLint’s own--stats. - The typescript-eslint docs recommend running
import/no-cycle,import/no-unused-modules,import/no-deprecatedandimport/no-named-as-defaultonly in CI or at push time, because TypeScript has no equivalent check. eslint-plugin-prettierparses every file a second time. Runningprettier --checkas its own step gives the same pass-or-fail result.
Why Is ESLint Slow?
ESLint is usually slow because of a few expensive rules, not because of TypeScript itself. With type-aware linting, a full ESLint run should take roughly as long as type checking. According to the typescript-eslint performance guide, the usual cause of a slowdown is type-aware rules asking TypeScript’s type checker for information. Measure your baseline first:
npx tsc --noEmit
If tsc is fast and lint is much slower, a rule or a config setting is to blame, not TypeScript itself.
How Do You Check Rule Timing With TIMING=1?
Setting the TIMING environment variable makes ESLint print the rules that took the most time. You don’t need to install anything:
TIMING=1 npx eslint .
This behaviour is described in ESLint’s Profile Rule Performance docs.
Be careful when you read the output. TypeScript builds type information when the first type-aware rule asks for it and caches it for every rule after that. As a result, the first type-aware rule in the run almost always looks slowest, even when it is cheap.
For a fair comparison, time each type-aware rule in its own run, with the others switched off. One way is to keep a trimmed config that contains a single suspect and point ESLint at it with -c/--config:
TIMING=1 npx eslint -c eslint.timing.config.js .
See Timing in ESLint Config Inspector
ESLint Config Inspector includes a timing view. According to its release notes, version 3.4.0 gave the dev and build commands a --stats flag and added a stats panel that runs ESLint timing when you ask for it. The timing view therefore needs Config Inspector 3.4 or later. Use the newest 3.x, because 3.4.1 fixed a build --stats crash that hit when ESLint reported a pass without parse timing (issue #333).
This --stats belongs to Config Inspector. ESLint has its own --stats flag, which puts timing numbers into each file’s lint result for a formatter to show. It does not open a panel.
Run these in the folder that holds your eslint.config.js:
# List the options available in your installed version
pnpx @eslint/config-inspector --help
# Static snapshot with timing data, written to dist/__eslint-config-inspector
pnpx @eslint/config-inspector build --stats
Running plain pnpx @eslint/config-inspector starts the inspector at http://localhost:7777. Pass --stats to the dev command (--help shows the exact form), open the stats panel, and look for the rules and plugins that account for most of the time. The first-type-aware-rule caveat applies in this view as well. The static build is a snapshot, so you can keep one from before a config change and compare it with one taken afterwards.
Which ESLint Rules Are Usually the Slowest?
The same four groups of ESLint rules come up again and again at the top of timing output:
- Type-aware rules. Presets such as
recommendedTypeCheckedadd rules that depend on the type checker. Setup is covered in linting TypeScript with ESLint. eslint-plugin-prettier. The plugin can only spot a formatting difference by running Prettier over the whole file, so every linted file gets parsed once by ESLint and again by Prettier. The ESLint and Prettier piece explains how the two tools divide the work.@stylistic/indent. It works out indentation for each token in each file, and in a big codebase that work piles up.eslint-plugin-importrules, especiallyimport/no-cycle. These rules make the plugin parse files and follow imports itself, separately from ESLint, so the cost grows with the size of the codebase. See ESLint plugins that improve code quality for what the plugin catches.
How to Fix Each Slow Rule
Each slow ESLint rule group has its own fix. Most of the recommendations below come from the typescript-eslint performance guide:
| Culprit | Why it’s slow | Fix |
|---|---|---|
| Type-aware rules | Call into the type checker | Limit them to source files with files, and narrow tsconfig include |
eslint-plugin-prettier | Parses each file a second time | Remove it and run prettier --check . |
@stylistic/indent | Works over every token | Remove it and let Prettier handle indentation |
import/named, import/namespace, import/default, import/no-named-as-default-member, import/no-unresolved | Duplicate TypeScript’s checks | Turn them off (no-unresolved only if the code uses import, not require) |
import/no-cycle, import/no-unused-modules, import/no-deprecated, import/no-named-as-default | Cross-file analysis with no TypeScript equivalent | Run them in CI or at push time only |
Check tsconfig as well. A tsconfig with no include, or with a glob such as **/*, makes the parser pre-parse far more files than you meant to lint, build output included. Limit include to the folders you really want linted:
{ "compilerOptions": { /* ... */ }, "include": ["src"] }
Use Prettier’s --check as a separate CI step instead of the plugin. For repeated local runs, ESLint’s --cache flag skips files that haven’t changed since the last run.
Example: Before and After
The before config below runs everything on every file:
// @ts-check
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
import stylistic from '@stylistic/eslint-plugin';
import prettierPlugin from 'eslint-plugin-prettier';
export default defineConfig({
files: ['**/*.{js,ts}'],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
plugins: { import: importPlugin, '@stylistic': stylistic, prettier: prettierPlugin },
rules: {
'prettier/prettier': 'error',
'@stylistic/indent': ['error', 2],
'import/named': 'error',
'import/no-unresolved': 'error',
'import/no-cycle': 'error',
},
});
The after config limits type-aware rules to src, removes the formatting plugins and the import rules TypeScript already covers, and turns import/no-cycle on only when an environment variable is set:
// @ts-check
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
const full = process.env.LINT_FULL === '1';
export default defineConfig(
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
{
files: ['src/**/*.ts'],
plugins: { import: importPlugin },
rules: {
...(full ? { 'import/no-cycle': 'error' } : {}),
},
},
);
With this config, the pre-commit hook runs npx eslint ., while CI runs LINT_FULL=1 npx eslint . and npx prettier --check ..
The table below is an illustrative example of how the TIMING=1 list changes after this config change. It is not a set of measured results:
| Rule | Before | After |
|---|---|---|
prettier/prettier | Near the top | Gone (replaced by prettier --check) |
@stylistic/indent | Near the top | Gone |
import/no-cycle | Near the top | Appears only in LINT_FULL=1 runs |
import/named, import/no-unresolved | Listed | Gone |
| First type-aware rule | Listed as slowest | Still first; compare with tsc time |
Conclusion
Measure before you remove anything. Start with a tsc baseline, then run TIMING=1 and the Config Inspector stats panel, and time type-aware suspects one at a time. Once you know which rules cost the most, keep local and pre-commit linting to fast, single-file rules and move cross-file checks and formatting to CI. Run TIMING=1 npx eslint . on your project now and work through the fix table for whichever rules come out on top.
FAQs
How do I see more than 10 rules in ESLint's TIMING output?
Set TIMING to a larger number or to all. TIMING=1 shows the ten longest-running rules, TIMING=50 shows fifty, and TIMING=all shows every rule. Each row gives the rule's time in milliseconds (rule creation plus execution) and its share of total rule time. To time a single rule, combine --no-config-lookup with --rule. This suits core rules that need no parser configuration.
Is ESLint's --cache safe to use with type-aware rules?
Not fully. ESLint's cache only asks whether a file or the config has changed. It ignores changes in the other files that a file depends on. Type-aware typescript-eslint rules and import-resolving rules from eslint-plugin-import can therefore report stale results in either direction: they can miss a new problem or keep reporting one that is already fixed. The typescript-eslint FAQ does not recommend --cache, so run CI without it.
How do I get per-file, per-rule timing from ESLint?
Run ESLint with its own --stats flag and a formatter that outputs stats data, for example npx eslint --stats -f json. Each result object gets a stats key with parse times, fix times and per-rule lint times for that file, while TIMING only summarises the whole run. Not every formatter outputs stats data. The first type-aware rule caching caveat applies to --stats as well.
Does parserOptions.projectService make typed linting faster than parserOptions.project?
Not reliably. typescript-eslint expected the Project Service, available from v8.0.0, to be faster, and says it is handling the missing speed gain as a bug. The main benefit is simpler configuration: you no longer need parserOptions.project globs, where recursive ** patterns cause extra disk IO. Keep allowDefaultProject small, because each file it matches slows linting noticeably, and by default it may match no more than 8 files.