Too much work per run
Optimize lint and typecheck ROI in CI
By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026
A developer pushes a one-line fix. ESLint scans 2,000 files. TypeScript re-checks the entire project. Four minutes pass. The lint step succeeds, as it does 95% of the time. You're billed for every one of those minutes, every push, every PR. Lint and typecheck are essential guardrails, but most teams run them in the most expensive way possible. That's fixable.
Symptoms
How to tell if lint and typecheck are wasting CI minutes
Open your CI run logs and look at the lint and typecheck steps. If any of these patterns look familiar, you're paying more than you need to:
-
Lint runtime doesn't scale with change size. A one-file PR takes the same 3–5 minutes to lint as a 50-file refactor. ESLint (especially with type-aware rules via typescript-eslint) re-parses the entire project graph on every run, regardless of what actually changed.
-
Very low failure rate. Most developers run linters in their editor or via pre-commit hooks. The CI lint step passes on 90–98% of runs. You're spending minutes to confirm what the developer already knows.
-
No caching between runs. ESLint and TypeScript both support incremental caching, but most CI workflows don't persist it. Every run starts cold, downloading, parsing, and checking from scratch.
-
Redundant formatting checks. Prettier and ESLint both run formatting rules, or worse, Prettier runs inside ESLint via eslint-plugin-prettier, doubling the work and blocking formatting on a full lint pass.
Metrics
Quantify the waste
Lint and typecheck are rarely the longest step in your pipeline, but they run on every push to every PR. The per-run cost is modest; the monthly total is not. Here's a typical mid-size TypeScript project on Linux runners:
Before optimization
At $0.006/min (Linux 2-core) · 22 working days
After optimization (60% faster)
Save $15/mo · $180/year · per workflow
That's one workflow on one repo. Most teams have multiple repos, and lint + typecheck runs on every CI workflow. Scale to 5 repos and you're looking at $900/year saved from lint configuration alone. On macOS runners at $0.062/min, multiply by 10. If your lint jobs are running on macOS unnecessarily, see overpowered runners for lightweight jobs.
Fix 1
Cache ESLint and TypeScript results between runs
ESLint supports a --cache flag that stores lint results per file. On subsequent runs, only files whose content has changed are re-linted. TypeScript's --incremental flag writes a .tsbuildinfo file that tracks the dependency graph, so only changed files and their dependents are re-checked. If your cache isn't restoring correctly, see our guide on fixing broken dependency caches.
The key detail for CI: use --cache-strategy content for ESLint, not the default metadata strategy. In CI, file timestamps change on every checkout (the clone is fresh), but the file content hasn't changed. Without content strategy, the cache is useless.
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm # Restore ESLint + TypeScript caches - uses: actions/cache@v4 with: path: | .eslintcache tsconfig.tsbuildinfo key: lint-${{ runner.os }}-${{ hashFiles('package-lock.json', '.eslintrc*', 'eslint.config.*', 'tsconfig.json') }} restore-keys: | lint-${{ runner.os }}- - run: npm ci - run: npx eslint . --cache --cache-strategy content - run: npx tsc --noEmit --incremental
The cache key includes your lockfile, ESLint config, and tsconfig.json. When config changes, the cache invalidates (correct behavior). When only source files change, the cache restores and ESLint/TypeScript only reprocess the diff. On a 2,000-file codebase, this typically cuts lint time from 3–5 minutes to under 1 minute on cache hit.
One caveat: ESLint's --cache is file-level, not rule-level. If you change your ESLint config, the entire cache must be rebuilt. The cache key above handles this by hashing the config files. For TypeScript, ensure your build scripts don't accidentally delete the .tsbuildinfo file. A rimraf dist step that's too broad is a common culprit.
Fix 2
Lint only changed files on pull requests
Even with caching, ESLint still needs to load your config, resolve plugins, and initialize the parser for every run. On PRs, you can skip all of that overhead for untouched files by passing only the changed file list to ESLint. The full-codebase lint still runs on main as a safety net.
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Need full history for diff - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci # Full lint on main, changed-files-only on PRs - name: Lint changed files if: github.event_name == 'pull_request' run: | CHANGED=$(git diff --name-only --diff-filter=d origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx') if [ -n "$CHANGED" ]; then echo "$CHANGED" | xargs npx eslint --cache --cache-strategy content fi - name: Lint all files if: github.event_name == 'push' run: npx eslint . --cache --cache-strategy content
The --diff-filter=d flag excludes deleted files (ESLint can't lint files that don't exist). The fetch-depth: 0 is required so git has the base branch ref to diff against.
One caveat: changed-file linting won't catch issues introduced by transitive dependencies. If you rename an exported type in file A, ESLint won't flag the broken import in unchanged file B. That's why the full lint on main matters, because it catches anything the PR-scoped lint misses. For TypeScript type-checking specifically, tsc --noEmit must always run against the full project because the type checker needs the complete dependency graph.
Fix 3
Use incremental TypeScript type-checking
TypeScript's --incremental flag saves a .tsbuildinfo file containing the dependency graph and per-file checksums. On subsequent runs, only changed files and their dependents are re-checked. On a large codebase, this can reduce tsc --noEmit from 180 seconds down to 20–30 seconds when few files changed.
{ "compilerOptions": { "incremental": true, "tsBuildInfoFile": "./tsconfig.tsbuildinfo", "noEmit": true, "skipLibCheck": true, // ... rest of your config } }
Three settings matter here. incremental enables the build graph cache. tsBuildInfoFile controls where the cache is written, and you should set it explicitly so your CI cache step can find it. skipLibCheck skips type-checking .d.ts files from node_modules, which rarely change and add significant overhead.
One caveat: incremental builds save state based on file content checksums. If your CI workflow runs a clean script that deletes the .tsbuildinfo file (e.g., rimraf dist tsconfig.tsbuildinfo), you lose the cache every run. Either exclude the .tsbuildinfo file from your clean script, or place it in a directory that's not wiped.
Fix 4
Shift formatting checks to pre-commit hooks
If your CI lint step fails less than 5% of the time, most of those failures are formatting issues that would have been caught locally. Pre-commit hooks run linting on staged files before the commit is created. Developers fix issues immediately, and CI never sees the failure. This doesn't eliminate the CI lint step entirely, since you still want a safety net, but it dramatically reduces how often CI does real work.
{ "lint-staged": { "*.{ts,tsx,js,jsx}": [ "eslint --cache --cache-strategy content --fix", "prettier --write" ], "*.{json,md,yml,yaml}": [ "prettier --write" ] } }
npx lint-staged
With lint-staged and husky, ESLint and Prettier only run on files that are being committed, not the entire codebase. This takes seconds locally and prevents formatting failures from ever reaching CI.
One caveat: pre-commit hooks are client-side and can be bypassed with --no-verify. They supplement CI; they don't replace it. Keep the CI lint step as a required check, but expect it to pass nearly every time once hooks are in place.
Reference
Complete optimized lint workflow
Here's all four fixes combined into a single workflow. ESLint caches results, TypeScript uses incremental checking, and PRs only lint changed files. Copy and adjust for your project.
name: Lint & Typecheck on: push: branches: [main] pull_request: concurrency: group: lint-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-node@v4 with: node-version: 20 cache: npm # Restore lint caches - uses: actions/cache@v4 with: path: | .eslintcache tsconfig.tsbuildinfo key: lint-${{ runner.os }}-${{ hashFiles('package-lock.json', '.eslintrc*', 'eslint.config.*', 'tsconfig.json') }} restore-keys: lint-${{ runner.os }}- - run: npm ci # PR: lint only changed files - name: ESLint (changed files) if: github.event_name == 'pull_request' run: | CHANGED=$(git diff --name-only --diff-filter=d origin/${{ github.base_ref }}...HEAD -- '*.ts' '*.tsx' '*.js' '*.jsx') if [ -n "$CHANGED" ]; then echo "$CHANGED" | xargs npx eslint --cache --cache-strategy content fi # main: lint everything - name: ESLint (full) if: github.event_name == 'push' run: npx eslint . --cache --cache-strategy content # Typecheck always runs full project (needs complete graph) - name: TypeScript run: npx tsc --noEmit --incremental
Reference
Caching by tool
Different lint and typecheck tools have different caching mechanisms. Here are the most common ones and how to enable them in CI:
| Tool | Cache flag | Cache file |
|---|---|---|
| ESLint | --cache --cache-strategy content | .eslintcache |
| TypeScript | --incremental | *.tsbuildinfo |
| Prettier | --cache | node_modules/.cache/prettier |
| Stylelint | --cache | .stylelintcache |
| mypy | (enabled by default) | .mypy_cache/ |
| Ruff | --cache | .ruff_cache/ |
For Python projects, Ruff is a drop-in replacement for Flake8 + isort that runs 10–100x faster. For JavaScript/TypeScript projects, Biome replaces ESLint + Prettier in a single Rust-based binary that's 10–25x faster. These are larger migrations, but if lint time is a significant share of your CI spend, the ROI is substantial.
Related guides
Dependency Cache Not Working
Fix cache misses that force cold installs on every CI run.
Reduce CI Setup and Install Overhead
Cut repeated npm ci and setup-node time across every job.
Speed Up Slow CI Pipelines
Find slow jobs and add targeted caching to cut pipeline time.
Overpowered Runners for Lightweight Jobs
Stop running lint on macOS or 16-core runners when Linux 2-core works.