Why is ESLint slow in a large Nx monorepo?

ESLint on its own is fast at syntactic rules: it parses each file, walks the AST, and moves on. Type-aware rules change that. To answer questions like "is this promise awaited?", typescript-eslint needs a full TypeScript program for the code being linted, including the type information for everything those files import.

In an Nx workspace, each project's lint target typically runs as a separate task. Each task builds its own program. When libraries are wired together through paths in a base tsconfig, imports resolve to the other libraries' source files rather than to prebuilt declarations. A shared core or utils library therefore gets parsed and type-checked again by every project that imports it. With hundreds of projects, most of the CPU time goes to rebuilding the same type graph, not to running rules.

How the parser is configured matters. With parserOptions.project, you list tsconfig files and the parser builds programs from them. A broad glob or a catch-all tsconfig can pull in far more files than the project being linted. The project-service style hands file-to-tsconfig resolution to TypeScript's own project service, the same mechanism editors use, which usually avoids over-inclusion. Option names and defaults have changed between releases, so check the typescript-eslint docs for the version you have installed rather than assuming a version.

Caching usually breaks next. If lint inputs include root configs, the base tsconfig, or every dependency's source, a single shared edit invalidates the cache for nearly every project.

This guide is for backend and platform engineers who maintain Nx workspaces with many projects.

        tsconfig.base.json  paths: @org/core -> libs/core/src (source)
                                 |
        +------------------------+------------------------+
        |                        |                        |
        v                        v                        v
+---------------+        +---------------+        +---------------+
| lint: api     |        | lint: billing |        | lint: auth    |
| (Nx task 1)   |        | (Nx task 2)   |        | (Nx task N)   |
+-------+-------+        +-------+-------+        +-------+-------+
        |                        |                        |
        v                        v                        v
+---------------+        +---------------+        +---------------+
| TS program #1 |        | TS program #2 |        | TS program #N |
| api src       |        | billing src   |        | auth src      |
| + core src    |        | + core src    |        | + core src    |
| + utils src   |        | + utils src   |        | + utils src   |
+-------+-------+        +-------+-------+        +-------+-------+
        |                        |                        |
        +------------------------+------------------------+
                                 |
                                 v
              +-------------------------------------+
              | core/utils type graph rebuilt N     |
              | times; running rules is the small   |
              | share of total CPU time             |
              +-------------------------------------+
 Parser: parserOptions.project globs --> may over-include files
         project service --> TS resolves the tsconfig for each file
 Nx inputs: root configs + base tsconfig + every dep's src
         one shared edit --> hash changes for most tasks --> cache miss

Each Nx lint task builds its own TypeScript program and re-parses shared libraries resolved through tsconfig paths, while over-broad cache inputs let one shared edit invalidate nearly every lint task.

Who should use these techniques?

The techniques in this guide are worth the effort when lint is a real bottleneck, not just a line item in CI. Typical signs:

  • The workspace has dozens to hundreds of projects, and shared libraries are imported widely through tsconfig paths.
  • The lint configuration enables type-aware rules from typescript-eslint, and lint is one of the slowest targets in CI.
  • Lint runs crash or stall from heap exhaustion, or CI runners have been resized mainly to keep lint alive.
  • Nx cache hit rates for lint stay low even when most of the workspace did not change.

The primary audience is the platform or build engineer who owns the root ESLint config, nx.json and the CI pipeline, because most of the changes below are workspace-wide. Backend engineers who own individual projects benefit too. They need to understand why typed rules are scoped to production source, and why a project-level config override has a cache cost for that project.

How does typed linting work with Nx task caching?

Two separate mechanisms are involved, and most of the slowness comes from how they interact.

Typed linting. For each file, ESLint asks the configured parser, @typescript-eslint/parser, to produce an AST. When type information is enabled through parserOptions.project or the project service, the parser also gets a TypeScript program that contains the file. Building that program means resolving the tsconfig, reading every file it includes, following imports, and binding and type-checking enough of the graph to answer type queries. Type-aware rules then use the parser services to reach the TypeScript type checker and ask about specific nodes, for example whether an expression's type is a Promise. The program is created once per lint process and reused across files in that process. It is not shared between separate processes, so each Nx lint task pays the construction cost again.

Nx task caching. Before running a task, Nx computes a hash from the task's declared inputs. These can include file sets, named inputs, inputs of dependency projects (the ^ prefix), runtime values and environment variables. If a cache entry exists for that hash, locally or in a configured remote cache, Nx replays the stored terminal output and exit code and restores any declared outputs without running ESLint. On a miss, Nx runs the task and stores the result under that hash.

Affected detection. nx affected compares the current commit to a base commit, maps the changed files to projects through the project graph, and adds every project that depends on them. Only those projects' tasks run, and each one still goes through the cache check above.

Put together: a task that misses the cache pays for a full TypeScript program build. Correct but broad inputs cause many misses. Missing inputs cause stale hits. The rest of this guide is about reducing the cost of a miss (scoping typed linting) and making hits both frequent and trustworthy (inputs, affected runs, remote cache).

How do you find where ESLint lint time actually goes?

Before changing config, measure on a cold cache so you see real ESLint work rather than Nx replaying results. Lint one representative project with nx lint <project> --skip-nx-cache and set the TIMING=1 environment variable. ESLint then prints the ten most expensive rules and their share of rule time. TIMING=all lists every rule. Run that on a few projects of different sizes, then time a full nx run-many -t lint --skip-nx-cache.

Compare the two numbers. If a full run takes far longer than the per-project cost times the project count divided by your parallelism, the overhead is outside the rules. Common causes are repeated TypeScript program construction or memory pressure. Program construction usually happens during parsing, so it won't appear under any single rule in the timing table. To isolate it, rerun with type-aware rules disabled and compare wall time.

Check whether the top rules are type-aware, such as no-floating-promises or no-misused-promises. Those rules need the full type checker, and they are where scoping decisions in the next section pay off.

Watch memory as well. Typed linting across many tsconfigs can exhaust the Node heap before CPU saturates, which shows up as crashes or heavy garbage collection.

Illustrative estimate (assumed numbers, not a benchmark): expected run time ≈ (projects × avg seconds per project) ÷ parallel workers. With 300 projects, 20 s each and 4 workers, that is 300 × 20 ÷ 4 = 1,500 s, about 25 minutes. If the measured run takes 50 minutes, the extra time is overhead, and more CI machines won't remove it.

#!/usr/bin/env bash
set -euo pipefail

for project in small-lib medium-lib web-app; do
  echo "=== $project ==="
  TIMING=1 npx nx lint "$project" --skip-nx-cache
done

/usr/bin/time -v npx nx run-many -t lint --skip-nx-cache --parallel=4 \
  2> run-many-time.log
grep -E 'Elapsed|Maximum resident' run-many-time.log

NODE_OPTIONS="--max-old-space-size=8192" \
  npx nx lint web-app --skip-nx-cache

A shell session that collects per-rule ESLint timings for a few projects, then measures wall time and peak memory of a full uncached Nx lint run to compare against the expected per-project cost.

How should you scope typed linting so it only runs where it pays off?

Typed linting is usually the biggest per-project cost, because every typed rule needs a full TypeScript program for the files being linted. That program should cover as few files as possible.

Split the config by file type

In flat config, apply the type-checked rule set through a files glob that matches only production source, such as src/**/*.ts. Give tests, scripts and config files a separate block with the non-typed recommended rules. Put generated code (ORM clients, protobuf output, OpenAPI stubs) in ignores. Nobody fixes lint findings in files a generator overwrites, so type-checking them only costs time.

Use a dedicated lint tsconfig

Point the parser at a tsconfig.lint.json whose include matches exactly what the typed block lints. A project's main tsconfig often pulls in tests, fixtures or broad globs, and the type checker loads every one of those files even when ESLint reports on none of them.

Audit typed rules by what they catch

Rule Backend bug it catches Keep?
no-floating-promises Unawaited DB writes, swallowed rejections Yes
no-misused-promises Async handlers passed where a sync callback is expected Yes
no-unsafe-assignment / no-unsafe-member-access any leaking from request bodies and parsed JSON Usually
restrict-template-expressions Mostly log-formatting style Often not

Disabling a rule allows that class of bug through, so record the reason for each one you turn off. Lint tsconfigs can also drift from build tsconfigs, for example in strict flags or path aliases. When that happens, ESLint and the compiler disagree about the code. Keep the shared options in one base config that both extend.

// eslint.config.mjs: typed rules only where they catch real bugs
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
  // Generated code is overwritten by its generator, so never lint or type-check it
  { ignores: ['src/generated/**', '**/*.pb.ts', 'dist/**'] },

  // Typed block: production source only, excluding tests
  {
    files: ['src/**/*.ts'],
    ignores: ['src/**/*.spec.ts', 'src/**/*.test.ts'],
    extends: [eslint.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
    languageOptions: {
      parserOptions: {
        // tsconfig.lint.json extends the shared base and includes only src/**/*.ts
        project: './tsconfig.lint.json',
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      '@typescript-eslint/no-floating-promises': 'error', // unawaited DB writes
      '@typescript-eslint/no-misused-promises': 'error', // async fn passed as sync callback
      // Off on purpose: findings were log-formatting style, not bugs
      '@typescript-eslint/restrict-template-expressions': 'off',
    },
  },

  // Non-typed block: tests, scripts and config files skip building a TS program
  {
    files: ['src/**/*.spec.ts', 'src/**/*.test.ts', 'scripts/**/*.ts', '*.config.{js,mjs,ts}'],
    extends: [eslint.configs.recommended, ...tseslint.configs.recommended],
  },
);

A flat config that applies type-checked rules only to production source through a dedicated lint tsconfig, lints tests, scripts and config files without type information, ignores generated code, and records why one typed rule is disabled.

How do you structure ESLint flat config in an Nx workspace?

Flat config fits this model well because each config file exports a plain array of config objects. A project's eslint.config.js can import the root array, spread it, and append one or two objects of its own. Put everything that applies workspace-wide in the root file: parser setup, the shared rule set, and the typed-linting block scoped by files. Project files should contain only the rules a project really needs to differ on, such as a stricter rule for a public API package or a relaxed one for a migration-heavy service.

Put a global ignore object in the root config, meaning an object with only an ignores key. List **/dist/**, **/coverage/**, **/tmp/**, generated clients and any other build output in it. Ignoring these paths keeps ESLint from walking or parsing files that are large, change often and never need linting. Give every rule block an explicit files glob, such as **/*.ts, so typed rules never try to run on JSON or config scripts.

Keep config evaluation cheap. Nx runs lint per project, so each task loads the config again. A config that globs the filesystem for tsconfigs, reads every package.json, or shells out to git repeats that work hundreds of times. Use static values or resolve paths relative to import.meta.dirname.

Each per-project config file adds maintenance cost. Every file is another place where rules can drift. A change to the root config is an input to every project's lint task, so it invalidates the lint cache across the workspace. Only create a project-level file when that project actually needs to override something.

// eslint.config.js (workspace root): shared by every project
import tseslint from 'typescript-eslint';

export default [
  // Global ignore: an object with only `ignores`, so ESLint never walks build output
  { ignores: ['**/dist/**', '**/coverage/**', '**/tmp/**', '**/generated/**'] },

  // Typed rules, scoped to TypeScript sources only (not JSON or config scripts)
  ...tseslint.configs.recommendedTypeChecked.map((config) => ({
    ...config,
    files: ['**/*.ts', '**/*.tsx'],
  })),
  {
    files: ['**/*.ts', '**/*.tsx'],
    languageOptions: {
      parserOptions: {
        projectService: true,
        // Static path, resolved once. No fs globbing, package.json scans or git calls
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
];

// libs/public-api/eslint.config.js: only for a project that really differs
import root from '../../eslint.config.js';

export default [
  ...root,
  {
    // Stricter contract for an externally consumed package
    files: ['**/*.ts'],
    rules: {
      '@typescript-eslint/explicit-function-return-type': 'error',
    },
  },
];

A root flat config holds a global ignores object, typed linting scoped by files, and a static tsconfigRootDir; a project config spreads the root array and adds a single rule override.

How do you make Nx lint caching produce reliable cache hits?

Start with the lint target's inputs. Most workspaces inherit something close to default plus every file in the workspace, so any edit anywhere invalidates every project's lint result. Narrow the inputs to the things that can actually change a lint outcome:

  • The project's own source files.
  • The root eslint.config.* and any shared config fragments it imports.
  • The project's lint tsconfig and every tsconfig it extends.
  • Any local custom rule packages.

Define these once as a named input in targetDefaults so every project uses the same definition.

Typed linting adds one more input. Rules that resolve types read declarations from upstream projects, so an upstream change can turn a passing lint result into a failing one. You do not need dependsOn: ["^lint"] for this, because lint does not consume another project's lint output. Model the dependency in inputs with the ^ prefix instead. Prefer ^production, or a narrower named input that covers only public API files, over ^default. This keeps upstream test files and fixtures from invalidating downstream lint.

Next, check the target's settings. Set cache: true. Leave outputs empty unless you write a report file, because Nx only needs the terminal output and exit code to replay a result.

Then verify the setup:

  1. Run nx show project <name> and read the resolved inputs.
  2. Lint twice, make a no-op change in an unrelated project, and lint again.
  3. Confirm the expected projects read from cache.

The trade-off is uneven. If inputs are too broad, lint is slow. If you miss an input, such as a shared tsconfig, a config fragment or upstream types, Nx returns a stale passing result. That is worse than slow lint, so when you are unsure whether something is an input, include it.

When should you use affected linting, remote caching and ESLint's own cache?

Use affected linting for pull requests and a full lint on main on a schedule. Affected detection maps changed files to projects through the project graph. Changes to the lockfile, a root ESLint config, a shared plugin or the Node version can change lint results for every project without clearly marking any of them as affected. The scheduled full run catches the drift that affected runs miss.

Remote or shared caching only helps when hashes are deterministic across machines. Pin the Node version, keep absolute paths and environment-specific values out of inputs, and confirm that a developer laptop and a CI runner produce the same hash for the same commit. Running a shared cache also means operating storage and controlling who can write artifacts. Anyone with write access can poison results for everyone else.

Set parallelism according to available memory, not core count. Each typed lint task can hold a full TypeScript program in memory, so eight cores with limited RAM will swap or crash. Raising the Node heap limit hides the problem. Runner sizing then depends on a flag that nobody remembers setting.

Mechanism Granularity Shared across machines Safe for type-aware rules
ESLint --cache Per file No No. A dependency's types can change while the linted file stays the same
Nx task cache Per project task Yes, with remote cache Yes, if inputs are complete

Treat ESLint's cache as a local convenience at most. Exclude its location from Nx inputs and add it to .gitignore.

What are the alternatives to running every check in ESLint, and what do they save?

Moving whole classes of checks out of the typed ESLint pass is often a bigger win than any cache tuning.

Check Better home What it saves
Type errors that typed rules duplicate tsc --noEmit per project A second type-check of the same program
Formatting, whitespace A formatter run on staged or changed files Rule evaluation per file, plus noisy diffs
Import ordering Formatter plugin or a separate untyped pass Keeps cheap checks out of the type-aware program build
Fast untyped rules Pre-commit hook on staged files Feedback in seconds instead of a CI round trip
Full typed lint CI, affected projects only Developer machines never pay for it

A pre-commit hook gives quick feedback, but git commit --no-verify skips it, and some developers will have hooks disabled. Treat the hook as a convenience and keep CI as the required gate.

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs: N = 300 projects, typed share p = 40/300 after scoping, typed lint t_t = 60 s, untyped lint t_u = 8 s, affected fraction a = 0.2, cache hit rate h = 0.7.

T = N × a × (1 − h) × (p × t_t + (1 − p) × t_u)

  • Baseline (everything typed, no affected, no cache): 300 × 60 = 18,000 s, or 300 min of compute.
  • Scoped, affected and cached: 300 × 0.2 × 0.3 × (8 + 6.93) ≈ 269 s, or about 4.5 min.

The estimate also assumes 8 parallel agents. With that parallelism, a developer's PR wait drops from roughly 37 minutes to under a minute. Substitute your own measured values for each input.

When should you avoid these optimizations?

Not every workspace needs this work, and some of the techniques carry risks that outweigh the speedup in certain setups.

  • Small workspaces. If a full uncached lint finishes in a time your team accepts, narrowing inputs and splitting configs adds maintenance with little return. Measure first, as described above, and stop if lint is not a meaningful share of CI time.
  • When no one owns the inputs. Narrow inputs are only safe if someone updates them when a new shared config fragment, tsconfig or custom rule package appears. If that ownership does not exist, keep inputs broad and accept slower lint rather than risk stale passing results.
  • Remote caching without write control. If you cannot restrict which machines write to the shared cache, or cannot make hashes deterministic across developer machines and CI, skip remote caching. A poisoned or mismatched cache costs more than the time it saves.
  • ESLint --cache with type-aware rules in CI. Its per-file invalidation does not track changes in imported types, so it can report a pass for code that now fails. Don't use it as a CI gate for typed linting.
  • Affected-only linting as the sole gate. Don't rely on affected runs alone for release branches or after changes to the lockfile, root ESLint config, shared plugins or Node version. Run a full lint in those cases.
  • Dropping typed rules to save time. Removing rules such as no-floating-promises or no-misused-promises from production source makes lint faster by letting real bugs through. Scope typed linting by file type and drop only rules whose findings you have reviewed and judged to be style rather than bugs.
  • Excluding code that is hand-edited. Only put code in ignores or the untyped block if it is truly generated or not shipped. Code that people edit and deploy should stay under the typed rules.

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.