Why Nx ESLint is slow: where lint time actually goes

If you run an Nx monorepo, slow lint usually has two distinct causes, and they need different fixes.

The run itself is slow. Most of the time goes to typescript-eslint building TypeScript programs for type-aware rules, not to the rules themselves. Before a rule like no-floating-promises can run, the parser has to resolve and type-check every file that the referenced tsconfig includes. That cost grows with the size of the program, not with the number of files you lint. Three setups make it worse:

  • Broad parserOptions.project globs that match many tsconfigs, so several overlapping programs get built.
  • tsconfigs whose include pulls in tests, dist, or generated output.
  • Per-project lint tsconfigs that reference shared paths and end up loading half the repo.

The run never hits the cache. A fast lint that reruns on every project is still slow in aggregate. If inputs are too broad, outputs aren't declared, or a root config change invalidates every project, Nx will re-execute targets it could have skipped.

Illustrative estimate (assumed numbers, not a benchmark): total lint time ≈ projects executed × (program build time + rule time) With an assumed 40 projects × (25 s + 5 s), you get 1,200 s. If scoping cuts program build to 8 s and caching cuts executed projects to 6, you get 6 × 13 s = 78 s.

Switching to a different linter is not the first fix. Measure, then tighten the scope and the caching.

 nx run-many -t lint  (N projects)
          │
          ▼
┌──────────────────────────┐   PROBLEM 2: run never hits the cache
│ Nx hashes lint inputs    │◄── - inputs too broad
│ for each project         │    - outputs not declared
└───────┬──────────┬───────┘    - root config change invalidates all
   hit  │          │ miss
        ▼          ▼
┌────────────┐ ┌──────────────────────────┐
│ restore    │ │ run ESLint on project    │
│ from cache │ └────────────┬─────────────┘
└────────────┘              ▼
               ┌──────────────────────────┐  PROBLEM 1: the run is slow
               │ typescript-eslint builds │◄─ - broad parserOptions.project
               │ TS program per tsconfig  │     globs: overlapping programs
               │ (resolve + type-check    │   - include pulls in tests,
               │ every included file)     │     dist, generated output
               └────────────┬─────────────┘   - lint tsconfig loads half
                            ▼                   the repo
               ┌──────────────────────────┐
               │ type-aware rules run     │
               │ (usually the small part) │
               └──────────────────────────┘

 total ≈ projects executed × (program build time + rule time)

Nx lint time has two separate sources: cache misses that multiply how many projects execute, and typescript-eslint program builds that make each executed project slow.

Measure before tuning: timing targets and rules

Start with one representative project, not the whole workspace. Run nx run <project>:lint --skip-nx-cache so Nx executes the target instead of replaying a cached result. A cache hit will hide the cost you are trying to find.

Next, set ESLint's TIMING=1 environment variable for the same run. ESLint then prints a table of the slowest rules and their share of rule execution time. This shows whether a few rules dominate, such as import-resolution rules or type-aware rules that walk large type graphs, or whether the cost is spread across many rules.

Rule timing does not include the TypeScript program build, because that work happens during parsing. To isolate it, run the project twice: once with your normal config, and once with the type-aware parser options and typed rules removed. The difference in wall-clock time roughly equals the cost of building the program plus the typed rules. Illustrative estimate, assumed inputs: if the typed run takes 22 s and the untyped run takes 9 s, about 13 s goes to type information. That points you toward scoping, not toward rule tuning.

Timings are noisy. The OS file cache, the Nx daemon, and whether a TypeScript program is already in memory all change the results. Take several cold runs, compare cold to cold, and keep warm numbers in a separate column.

Write the baseline down: cold and warm durations for the representative project, plus nx affected -t lint --skip-nx-cache for a typical change. Every later optimization gets measured against these numbers.

#!/usr/bin/env bash
# Record a lint baseline: cold vs warm runs, typed vs untyped, project vs affected.
# Assumes eslint.config reads LINT_UNTYPED=1 to drop parserOptions.project
# and typed rules (a convention you add yourself, not an ESLint feature).
set -euo pipefail

PROJECT="${1:-my-app}"   # representative project (placeholder)
RUNS=3
OUT=lint-baseline.csv
echo "scenario,run,kind,seconds" > "$OUT"

time_it() {  # time_it <scenario> <run> <kind> <command...>
  local scenario=$1 run=$2 kind=$3; shift 3
  local start end
  start=$(date +%s.%N)
  "$@" > /dev/null
  end=$(date +%s.%N)
  echo "$scenario,$run,$kind,$(echo "$end - $start" | bc)" >> "$OUT"
}

for i in $(seq 1 "$RUNS"); do
  # Cold: clear the Nx cache and stop the daemon so nothing is warm in Nx.
  nx reset > /dev/null
  time_it typed "$i" cold nx run "$PROJECT":lint --skip-nx-cache
  # Warm: same command again with the daemon and OS file cache already warm.
  time_it typed "$i" warm nx run "$PROJECT":lint --skip-nx-cache

  nx reset > /dev/null
  time_it untyped "$i" cold env LINT_UNTYPED=1 nx run "$PROJECT":lint --skip-nx-cache

  nx reset > /dev/null
  time_it affected "$i" cold nx affected -t lint --skip-nx-cache
done

# One extra run to see which rules dominate rule execution time.
TIMING=1 nx run "$PROJECT":lint --skip-nx-cache

column -s, -t "$OUT"

A repeatable baseline script that records several cold and warm lint durations for one project and for the affected set, compares typed against untyped runs to separate TypeScript program cost from rule cost, and finishes with a TIMING=1 run to list the slowest rules.

Scoping type-aware lint with eslint flat config in Nx

…against that baseline, and type-aware rules are usually the largest target.

Give the type checker less to load

Type-aware rules build a TypeScript program, so their cost tracks how many files that program contains. If your installed typescript-eslint version supports projectService, prefer it: it resolves each linted file to its nearest tsconfig the way an editor does, instead of loading every tsconfig you list. Check the typescript-eslint docs for your version to confirm support and options. Otherwise, point parserOptions.project at narrow per-project tsconfigs such as tsconfig.lib.json and tsconfig.spec.json, not a root config that includes the whole workspace.

Exclude dist, coverage and generated code in two places. Remove them from the tsconfig include so they never enter the program, and add them to ESLint ignores so they are never linted. Doing only one leaves part of the cost in place.

Apply type-checked rules only where they help

Scope the type-checked rule sets to TypeScript source globs. For JS files, config files, tests and generated code, apply typescript-eslint's disable-type-checked config. Those files gain little from type-aware rules but still pay the cost of building a program.

What flat config changes

Flat config replaces cascading .eslintrc files with one explicit array. Each file's effective configuration is visible in one place, so glob scoping is easier to audit and Nx inputs can list the config files that actually affect a project. Flat config does not make lint faster by itself. The gains come from the scoping it makes easier. Nx provides a generator that converts existing configs. Check the Nx docs for your version to find its name and behavior.

// libs/orders/eslint.config.mjs: flat config scoped for type-aware lint
import tseslint from 'typescript-eslint';

const TS_SOURCE = ['src/**/*.ts', 'src/**/*.tsx'];

export default [
  // Global ignores: never lint build output, coverage or generated code.
  // Also remove these paths from the tsconfig "include" so they never enter the program.
  { ignores: ['dist/**', 'coverage/**', 'src/generated/**'] },

  // Type-checked rules apply only to TypeScript source files.
  ...tseslint.configs.recommendedTypeChecked.map((config) => ({
    ...config,
    files: TS_SOURCE,
  })),
  {
    files: TS_SOURCE,
    languageOptions: {
      parserOptions: {
        // Each file resolves to its nearest tsconfig, as an editor would.
        // Confirm your typescript-eslint version supports projectService.
        projectService: true,
        // Fallback for older versions: list narrow tsconfigs instead.
        // project: ['./tsconfig.lib.json', './tsconfig.spec.json'],
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },

  // Turn type-aware rules off where they add cost but little value.
  // Keep this block last so it overrides the type-checked config above.
  {
    files: ['**/*.js', '**/*.mjs', '**/*.cjs', '*.config.ts', '**/*.spec.ts', '**/*.test.ts'],
    ...tseslint.configs.disableTypeChecked,
  },
];

A per-project flat config that ignores dist, coverage and generated code, applies typescript-eslint's type-checked rules only to TypeScript source with projectService (or narrow tsconfigs as a fallback), and turns type-aware rules off for JS, config and test files.

How the Nx lint cache and ESLint --cache work together

Nx and ESLint cache at different levels. The Nx computation cache works on whole tasks. Before running lint for a project, Nx hashes the task's inputs. If the hash matches a stored result, locally or in a remote cache, Nx replays the terminal output and skips ESLint entirely. ESLint's --cache only helps when the task does run. It skips files whose content and effective config have not changed since the last run.

To make lint cacheable, set cache: true for the lint target in targetDefaults in nx.json. Then define its inputs, usually the project's default named input plus the root ESLint config and the project's ESLint config, referenced with {workspaceRoot} and {projectRoot} tokens. With the @nx/eslint plugin, inferred targets may already do this, so check your version. Inputs that are too narrow produce stale passes: if you edit a shared rule and the hash doesn't change, Nx replays an old green result. Inputs that are too broad, such as every file in the workspace, change the hash on every commit, so you never get a cache hit.

To confirm hits, check the run summary. Nx reports how many tasks it read from the cache and labels each task's output as local or remote cache.

ESLint writes .eslintcache in the working directory unless you set --cache-location. It tracks each file on its own. If you change an exported type in file A, file B, which imports A, still counts as unchanged and is skipped, so type-aware rules on B are not re-run.

Nx computation cache ESLint --cache
Scope Whole lint task per project Individual files within a run
Invalidated by Any change in declared inputs File content or config change
Shared across machines Yes, with a remote cache No, local file only
Main risk Stale passes from narrow inputs Missed cross-file type errors
{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "sharedGlobals": []
  },
  "targetDefaults": {
    "lint": {
      "cache": true,
      "inputs": [
        "default",
        "{workspaceRoot}/eslint.config.mjs",
        "{projectRoot}/eslint.config.mjs",
        { "externalDependencies": ["eslint"] }
      ]
    }
  }
}

An nx.json fragment that makes the lint target cacheable and hashes the project's sources, the root and project flat ESLint configs, and the installed ESLint version, so a rule change invalidates the cache while unrelated workspace edits do not.

Running less lint in CI with nx affected and parallelism

nx affected compares two commits and lints only the projects whose files changed, plus the projects that depend on them. It only works if both commits exist locally. Pass --base as the merge base with the target branch, or as the last successful main-branch commit, and pass --head as the commit under test. Many CI checkouts default to a shallow clone, which leaves the base commit out of history. The comparison then fails, or a fallback computes a different and wrong set of projects. Fetch full history, or at least enough history to include the base commit, before running affected.

Choose a --parallel value based on runner memory, not CPU count. Each type-aware ESLint process builds its own TypeScript program, so memory use grows roughly linearly with parallelism. Measure the peak resident memory of your largest project's lint run. Divide the runner's available memory by that figure and leave headroom for the OS and Nx itself. Out-of-memory kills often show up as an unexplained exit code with no lint output, so cap parallelism before you raise it.

When the root ESLint config or a shared tsconfig changes, every project becomes affected, provided those files are part of the lint target's inputs. This is the correct outcome, because a changed rule or compiler option can change results anywhere in the workspace. Don't narrow inputs just to avoid these full runs.

The quieter risk comes from a project graph that is missing edges. Nx cannot see some dependencies, such as code generation, dynamic imports, or shared files outside any project. If those are not declared in implicitDependencies, affected skips projects that should have been linted, and CI passes when it should fail.

When to avoid these optimizations and the trade-offs involved

Each optimization in this guide saves time by assuming that some input did not change or does not matter. When that assumption is wrong, lint passes code it should have flagged, and it gives no sign that anything was skipped.

Optimization What you give up Typical failure
Narrowing type-aware rules Detection of floating promises and unsafe any flows Unawaited async calls reach production
Narrow Nx inputs Invalidation from shared tsconfigs, workspace-local plugins, and dependency types A cached pass on code that would now fail
ESLint --cache Cross-file freshness Stale type-aware results
Remote caching Local control over artifacts Unclear write access, tampered artifacts, misses across Node or OS versions
Flat config migration Short-term stability Compatibility shims, manual cleanup, mixed configs for a while

ESLint's cache needs the most care. It decides whether to re-lint a file based on that file and its config. Type-aware rules, however, also read types from other files. If an imported function changes to return a promise, the unchanged caller is still skipped, and the new floating promise goes unreported.

Remote caching is a trust decision. Limit write access to CI so a misconfigured laptop cannot publish results that everyone else reuses.

Many teams split the work by context. The editor and pre-commit hooks run a fast, syntax-only rule set. A CI or pre-merge job runs the full type-aware set without ESLint's file cache, so that result is the one that gates merges.

Alternatives and the impact on cost and engineering time

Option Migration effort CI compute Risk of missed bugs
Tune existing ESLint (measure, cache, scope) Low: config and target changes Falls with cache hits and affected Low: same rules run
Type-aware rules only in CI Low Unchanged in CI, cheaper locally Medium: issues surface at PR time
tsc for type errors, syntactic lint only Medium: drop type-aware rules Lowest Higher: tsc does not flag unhandled promises or unsafe any flows
Different linter High: rule parity, plugins, editor setup Possibly lower Gaps until rules match

Tune first because a new linter does not fix linting the whole repo on every push, a cold cache, or one expensive rule. Those are measurement and scoping problems, and they follow you to any tool. Switching also has a hidden cost: you must re-check that every rule you depended on still exists.

Illustrative estimate (assumed numbers, not a benchmark)

Weekly hours saved = engineers × lint runs per day × seconds saved per run × 5 ÷ 3600

The assumed inputs are 20 engineers, 10 runs per day and 40 seconds saved per run: 20 × 10 × 40 × 5 ÷ 3600 ≈ 11 hours per week. To estimate CI savings, multiply PRs per day by the runner minutes saved per pipeline. That figure also shows whether you can move to smaller runners.

Checklist (low effort, high payoff first)

  1. Time targets and individual rules.
  2. Enable the Nx cache for lint and ESLint --cache locally.
  3. Use nx affected in CI.
  4. Limit type-aware rules to the files that need them.
  5. Split rule sets by context: editor versus CI.
  6. Evaluate other linters only after steps 1–5.

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.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

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.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • 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.