Diagnosing where Nx lint time actually goes
If you run lint across a few hundred Nx projects, a slow nx run-many -t lint rarely means ESLint itself is slow. More often, Nx is re-executing tasks it should have replayed from cache. Separate those two costs before you change any configuration.
Start with one representative project. Run its lint target with --skip-nx-cache to force real execution, and note the wall-clock time. That number is your raw ESLint cost. Run it again with the TIMING=1 environment variable set. ESLint then prints the rules that consumed the most time, so you can see whether one or two rules dominate.
Next, run the full lint across the workspace twice in a row without touching any files. The second run should be almost all cache hits. If it still takes minutes, the cause is the Nx inputs, not ESLint.
| Bottleneck | Symptom | Where to look next |
|---|---|---|
| Per-file rules | TIMING=1 shows a few rules taking most of the time |
Rule selection and rule options |
| Type-aware parsing | Even small projects are slow, and parser setup outweighs any single rule | Parser project settings and type-checked rule sets |
| Scheduling and cache misses | Single project is fast, but the second full run is still slow | Lint target inputs and cache configuration |
Illustrative estimate (assumed numbers, not a benchmark): total time ≈ (projects × miss rate × average lint time) ÷ parallelism. Assume 300 projects, 8 s per project, and parallelism of 4. A 90% miss rate gives 300 × 0.9 × 8 ÷ 4 = 540 s. A 10% miss rate gives 60 s. Making each rule faster cannot match the gain from fixing the miss rate.
#!/usr/bin/env bash
set -euo pipefail
PROJECT=billing-api
echo "== Single project, cache skipped =="
time npx nx run "$PROJECT:lint" --skip-nx-cache
echo "== Single project, rule timing =="
TIMING=1 npx nx run "$PROJECT:lint" --skip-nx-cache
echo "== Full run 1 (warm-up) =="
time npx nx run-many -t lint --parallel=4
echo "== Full run 2 (should replay from cache) =="
time npx nx run-many -t lint --parallel=4
A diagnostic script that times one project's lint with the Nx cache bypassed, uses ESLint's TIMING=1 output to find costly rules, and runs the full workspace lint twice to show whether repeat runs are actually served from cache.
Who should use this approach
This guide is for engineers who own CI or developer tooling in an Nx monorepo where lint runs across many projects, typically dozens to hundreds. It fits teams in three situations:
- Platform or build engineers responsible for CI minutes and pipeline wall time, who see lint as a large share of either.
- Teams using type-aware typescript-eslint rules, where each lint process builds a TypeScript program and a cache miss is expensive.
- Teams sharing a remote Nx cache across CI and developer machines, where wrong inputs either waste the cache or, worse, replay stale passing results to everyone.
If your workspace has a handful of projects and lint finishes in well under a minute, most of this is unnecessary. Plain ESLint with --cache and default Nx settings is usually enough.
When to apply it
Use these techniques when the diagnostic run above points at Nx rather than ESLint. Concretely:
- The second full
run-many -t lintwith no file changes still executes many tasks instead of replaying them. - Small, unrelated edits, such as a change to one library or a root-level file, trigger lint in almost every project.
- CI lint time grows roughly with the total number of projects, not with the size of each change.
- You are adding a remote cache, migrating to flat config, or enabling type-aware rules, all of which change what lint depends on.
If the single-project run with --skip-nx-cache is the slow part and TIMING=1 shows a few dominant rules, start with rule selection and parser settings instead. Input scoping will not make an individual ESLint run faster.
How the fix works
The approach has four parts, each covered in a section below:
- Understand the hash. Nx fingerprints every lint task from its declared inputs and replays a stored result when the fingerprint matches. Lint is only as cacheable as those inputs are precise.
- Scope the inputs. Declare exactly the files and tool versions that can change lint output: project sources including tests, the root and project ESLint configs, custom rules, the tsconfig files the parser reads, and the ESLint and plugin versions. Exclude generated files, build output and ESLint's own cache file.
- Structure the configs. A stable root flat config plus small per-project configs keeps inputs simple, and scoping type-aware rules to source files limits how much TypeScript each task has to load.
- Run fewer tasks, then verify.
nx affectedlimits CI to changed projects, ESLint--cachehelps local reruns, and parallelism is tuned against memory. A short checklist confirms the cache invalidates when it should and hits when it should.
How Nx decides a lint task is a cache hit
Nx answers that question by fingerprinting each task before running it. For a lint target, it collects every input the target declares, hashes them together, and looks up the result in the local cache and, if configured, the remote cache. If an entry with that hash exists, Nx replays the stored terminal output and skips ESLint. If no entry exists, the task runs and its result is stored under the new hash.
project source files ──┐ (named inputs: default, production)
project config ────────┤ (target options, project.json / package.json)
root config files ─────┼──► task hash ──► cache lookup ──┬─► hit: replay output
tool versions ─────────┤ └─► miss: run ESLint, store
dependency inputs ─────┘ (^default / ^production)
What goes into the hash
Named inputs are reusable file sets defined in nx.json. default usually means every file in the project plus any shared globals. production is default with test files, specs and dev-only configs removed. A lint target typically references default, since lint checks tests too. It also adds root-level files that affect every project's result, such as the root ESLint config and the base tsconfig. External dependencies are hashed by resolved version, so upgrading an ESLint plugin changes the hash even when no source file changed.
Why broad inputs hurt
The hash does not know which file a rule actually reads. If a lint target lists a root file that changes often, or a glob over the whole workspace, that file feeds every project's hash. Editing it gives all 300 projects new hashes and a 100% miss rate. With the assumed numbers from the illustrative estimate above, that is a full 600 s run. The fix is to declare as inputs only the files that can change lint output.
+-------------------------+
| project source files |--+ named input: default (incl. specs)
| project config |--+ target options, project.json
| root config files |--+ root ESLint config, base tsconfig
| external dep versions |--+ resolved ESLint / plugin versions
| dependency inputs |--+ ^default / ^production
+-------------------------+ |
v
+-----------------+
| task hash |
+-----------------+
|
v
+-------------------------+
| lookup: local / remote |
+-------------------------+
| |
hash found hash not found
v v
+--------------+ +----------------------+
| HIT: replay | | MISS: run ESLint, |
| stored output| | store under new hash |
+--------------+ +----------------------+
Broad input: a frequently edited root file feeds every lint hash
root file edited --+--> hash(proj-001) changes --> miss
+--> hash(proj-002) changes --> miss
+--> ... all 300 projects --> miss, full rerun
Each lint task's declared inputs are hashed into one fingerprint that Nx checks against the local and remote cache, so one broad root-level input changes every project's hash at once.
Scoping lint target inputs correctly
A lint result depends on three things: the source being linted, the rules applied to it, and the tool versions running those rules. In nx.json, set targetDefaults.lint.inputs to default, which covers every file in the project and therefore includes tests. Add {workspaceRoot}/eslint.config.mjs, any shared config or custom-rule directories, the tsconfig files your parser reads, and an externalDependencies entry for eslint and your plugins. Using externalDependencies means only real version changes invalidate the hash, so unrelated churn in the lockfile is ignored.
| Mistake | Effect |
|---|---|
| Generated files or build output in inputs | Needless misses after every build |
| Whole lockfile as input | Misses on unrelated dependency bumps |
| Root config, plugin, custom rule or tsconfig omitted | Stale passing results after rule changes |
production inputs for lint |
Test files drop out of the hash, so edits to them never trigger a re-lint |
Dependency inputs are a real trade-off. When type-aware rules are enabled, a change to an exported type in a library can produce new errors in projects that consume it. Adding ^default captures that, but it also makes every edit to a widely used library invalidate its consumers. If your type-aware rules are few and narrow, ^production is cheaper because it ignores test files in dependencies. Either way, decide deliberately and do not let the default make the choice for you.
Do not treat ESLint's --cache file as an input or an output. Point --cache-location at a path outside your declared inputs, or exclude it with a negated pattern such as !{projectRoot}/.eslintcache. Leave it out of outputs as well, so Nx never restores a stale cache file on top of fresh sources.
{
"namedInputs": {
"sharedGlobals": [],
"default": [
"{projectRoot}/**/*",
"!{projectRoot}/src/generated/**/*",
"!{projectRoot}/dist/**/*",
"sharedGlobals"
],
"production": [
"default",
"!{projectRoot}/**/*.spec.ts",
"!{projectRoot}/**/*.test.ts"
]
},
"targetDefaults": {
"lint": {
"cache": true,
"inputs": [
"default",
"^production",
"{workspaceRoot}/eslint.config.mjs",
"{workspaceRoot}/tools/eslint-rules/**/*",
"{workspaceRoot}/tsconfig.base.json",
"!{projectRoot}/.eslintcache",
{ "externalDependencies": ["eslint", "typescript-eslint"] }
],
"outputs": ["{options.outputFile}"]
}
}
}
An nx.json that hashes lint on all project files including tests, the root ESLint config, custom rules, the base tsconfig and the eslint and typescript-eslint versions, excludes generated output and .eslintcache, and uses ^production to catch dependency type changes.
Flat config and type-aware rules without the overhead
The structure that keeps inputs simple is one root eslint.config file that defines shared rules, with a small eslint.config in each project that imports the root array and adds or overrides a few entries. Each lint task then depends on exactly two config files: the root one, declared once in your shared lint inputs, and the project's own, already covered by {projectRoot}/**/*. You don't need to chase extends chains or plugin-specific overrides scattered across the repo.
The trade-off is that any edit to the root file invalidates every project's lint cache. Keep the root config stable. Put rules you are still tuning, or that only one team cares about, in project configs, where a change reruns only that project.
Type-aware rules cost more than plain AST rules because typescript-eslint has to build a TypeScript program before it can answer type questions. If a project points at a broad tsconfig, or at several overlapping ones, the parser type-checks far more files than it lints. Apply type-aware rule sets only to files globs for source code, and turn them off for tests, scripts and generated code. The project-service approach in typescript-eslint helps here: it resolves the nearest tsconfig for each file, much as an editor does, so you don't have to list tsconfig globs by hand.
Don't drop type-aware rules just to gain speed. no-floating-promises and the no-unsafe-* family catch real defects in async and I/O-heavy backend code. Keep them where that code lives.
Moving from eslintrc to flat config is incremental migration work. Treat it as structural cleanup, not as a guaranteed speedup.
// libs/orders-api/eslint.config.mjs
// Assumes the root eslint.config.mjs registers the typescript-eslint parser and plugin
// (for example by spreading tseslint.configs.recommended) and holds only stable, shared rules.
import baseConfig from '../../eslint.config.mjs';
export default [
// Reuse the root array as-is. Edits to the root invalidate every project's lint cache.
...baseConfig,
// Type-aware rules apply only to source files in this project.
{
files: ['src/**/*.ts'],
// Keep tests and generated code out of the type-aware pass.
ignores: ['src/**/*.spec.ts', 'src/**/*.test.ts', 'src/generated/**'],
languageOptions: {
parserOptions: {
// The project service resolves the nearest tsconfig for each file, like an editor,
// so no hand-maintained list of tsconfig globs is needed.
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// These catch real defects in async, I/O-heavy code, so they stay on here.
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
},
},
// A rule this team is still tuning lives here, not in the root config.
// Changing it reruns lint for this project only.
{
files: ['src/**/*.ts'],
rules: {
'max-lines-per-function': ['warn', { max: 80 }],
},
},
];
A per-project flat config that extends the shared root array, scopes type-aware typescript-eslint rules to source files through the project service, and keeps a still-changing rule local so editing it reruns lint only for this project.
Running fewer tasks: nx affected, ESLint cache and parallelism
The three levers below cut work at different layers, so check which layer is actually slow before you use one.
nx affected in CI
nx affected -t lint lints only projects whose files, or whose dependencies' files, changed between a base and a head commit. Accuracy depends on those references. On a pull request, set --base to the merge-base with the target branch and --head to the PR commit. On the main branch, set the base to the last commit that passed CI, not HEAD~1, or a failed run's changes skip lint on the next push. Shallow clones are the common silent failure. If the base commit is not in the clone, the comparison breaks: depending on setup, the job errors, treats everything as affected, or uses a nearby commit and lints too little. Fetch enough history to include the base.
ESLint --cache as a second layer
ESLint's --cache skips unchanged files within a task. Give each project its own --cache-location so runs don't overwrite each other's caches. This helps with local reruns after a one-file edit. It adds little when Nx restores the whole task result, because ESLint never runs.
Parallelism
--parallel defaults low. Start near your core count. Then watch memory: each type-aware lint process holds a TypeScript program, so too many at once causes swapping or out-of-memory kills. Compare wall time and peak memory at several values on your real CI runner.
| Lever | Best for | Avoid when |
|---|---|---|
nx affected |
CI on PRs and main | Base commit missing or wrong |
ESLint --cache |
Local iterative reruns | Nx cache hits dominate |
--parallel |
Many small or untyped projects | Type-aware projects exhaust RAM |
Alternatives and impact on cost and engineering time
Each alternative saves time differently, and most of them do not change how often Nx has to run ESLint at all.
| Alternative | What it reduces | Trade-off |
|---|---|---|
| Tuning ESLint rules only | Per-project run time | Cache misses still re-run every project |
| Dropping type-aware linting | Per-project run time, often a lot | Loses checks such as floating-promise detection |
| Editor or pre-commit linting only | CI minutes | Nothing enforced; hooks get skipped |
| Splitting CI runners | Wall-clock wait | Same or more total CI minutes, plus setup cost per runner |
| Scoping lint target inputs | Number of tasks that run | Ongoing upkeep of input definitions |
Illustrative estimate (assumed numbers, not a benchmark)
Lint compute ≈ N × (1 − h) × t, where N is the number of projects, h is the cache hit rate and t is the average per-project lint time.
Assume N = 300 and t = 40 s:
- Before scoping (assumed h = 0.20): 300 × 0.80 × 40 s = 9,600 s, about 160 CI minutes.
- Rule tuning only (assumed t = 30 s, h = 0.20): 7,200 s, about 120 minutes.
- After scoping (assumed h = 0.85, t = 40 s): 1,800 s, about 30 minutes.
With these assumed inputs, the illustrative arithmetic works out as follows: a 25% faster ESLint saves about 40 minutes, while raising the hit rate saves about 130 minutes. If you also assume the work spreads evenly across 8 parallel agents, developer wait drops from roughly 20 minutes to roughly 4, before agent startup overhead. Splitting runners would cut the 20 minutes of wait but leave the 160 billed minutes in place. These figures come from the assumed inputs above, not from measurements. Substitute your own project count, hit rate and per-project time from the diagnostic run.
The ongoing cost is upkeep. Each time a shared config, plugin or tsconfig moves, the input definitions need updating. If an input is missing, Nx returns a cache hit when the result should have changed, and lint errors pass CI silently. Budget review time for every config change, and have someone own the inputs file.
Verification checklist before you trust the cache
Inputs that are too narrow fail silently. Lint keeps passing from cache while real errors go unreported. Before you rely on the new configuration, and especially before you enable a shared remote cache, have the author or a teammate run this checklist before the change merges.
Run the checklist
-
Confirm a clean baseline. Run
nx run-many -t linttwice with no changes in between. Every task in the second run must come from cache. A miss here means some input changes between runs, such as a generated file, a timestamp, or an environment variable that is in the hash. Fix this first, because the later checks depend on a stable baseline. -
Touch one leaf project. Add a comment to a source file in a library that nothing else imports, then lint again. Only that project should rerun. Repeat with a library that has dependents. Its dependents should rerun only if you deliberately added dependency inputs, for example for type-aware rules. If more projects rerun than you expected, your inputs are too broad. If fewer rerun, they are too narrow.
-
Touch the root ESLint config. A harmless edit to the root flat config should invalidate every lint task. Any project that still reports a cache hit is missing the root config from its inputs.
-
Touch shared TypeScript and plugin inputs. Edit the base tsconfig. Every project that uses type-aware linting must rerun. Then change a plugin, either by editing a local custom rule or by changing the plugin's lockfile entry. Every project that loads that plugin should rerun. These are the inputs most often left out, and leaving them out causes stale passes.
-
Revert and re-lint. After you undo the edits, the tasks should be cache hits again. This shows the hash depends on file contents, not on modification history.
Run the checklist again after every Nx migration, ESLint upgrade, or change to shared configuration. These changes often add or move config files without updating your inputs.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx eslint slow, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.