Speeding up slow lint-staged runs Jump to heading

A pre-commit hook that takes twenty seconds will be bypassed. Not maliciously β€” people commit with --no-verify β€œjust this once” when they are in a hurry, then again, and soon the hook protects nothing. lint-staged is supposed to be fast because it processes only staged files, but real configurations accumulate slow parts: a linter loading a large rule set, a type check over the whole project, a tool started once per file, caches disabled. This page shows how to measure where the time goes and cut it down without dropping the checks that matter, within lint-staged formatting automation.

When to use this approach Jump to heading

  • The pre-commit hook takes more than a few seconds on a typical small commit.
  • People routinely commit with --no-verify, and the reason is speed.
  • The hook got slower after adding a tool or growing the repository.
  • You have added type checks or other project-wide steps, as in running type checks alongside lint-staged.

Step 1 β€” Measure before changing anything Jump to heading

Time the whole hook on a realistic commit, then each task. --debug prints task timings; running each command by hand on the same files confirms them.

git add src/billing/totals.ts src/billing/totals.test.ts
time npx lint-staged --debug 2>&1 | grep -E 'Running tasks|finished|took'
# Time individual tools on the same files
time npx eslint src/billing/totals.ts src/billing/totals.test.ts
time npx prettier --check src/billing/totals.ts src/billing/totals.test.ts
Where a slow hook spent its timeAn illustrative pre-commit run on a two-file commit. Most of the time went to a full type check and to ESLint starting with a large plugin set and no cache. Prettier and the lockfile check were negligible.seconds per task on a two-file commit (illustrative)tsc, full14 seslint, no cache6.5 sprettier0.6 slockfile check0.3 stwo tasks accounted for nearly all of it β€” fix those and leave the rest

Step 2 β€” Turn on every tool’s cache Jump to heading

Most linters can cache results per file, keyed on content and configuration. ESLint does not cache by default; turning it on often halves its time on repeat runs.

// .lintstagedrc.mjs
export default {
  "*.{ts,tsx,js}": [
    "eslint --cache --cache-location node_modules/.cache/eslint/ --fix",
    "prettier --write --cache",
  ],
  "*.py": ["ruff check --fix", "ruff format"],   // ruff caches by default
};
# Verification: second run on the same files is much faster
time npx eslint --cache --cache-location node_modules/.cache/eslint/ src/billing/totals.ts
time npx eslint --cache --cache-location node_modules/.cache/eslint/ src/billing/totals.ts

Keep cache directories inside node_modules/.cache or another ignored path so they never get committed.

Step 3 β€” Start each tool once, not once per file Jump to heading

lint-staged passes all matching files to a command in one invocation, but some configurations defeat that β€” a shell loop, a wrapper script that processes one file at a time, or a function returning a command per file. Process start-up dominates for tools like ESLint that load many plugins.

// Slow: one ESLint process per file
"*.ts": (files) => files.map((f) => `eslint --fix ${f}`),

// Fast: one ESLint process for all staged files
"*.ts": "eslint --cache --fix",
Per-file processes against one processStarting a linter once per staged file repeats its plugin loading and configuration parsing for every file. Starting it once with all files pays that cost a single time, which matters most for tools with heavy start-up.one process per fileone process, all filesstart-up costΓ— number of filesΓ— 110-file commit~10Γ— slowerbaselineparallelismby lint-stagedinside the toolcheck your config for per-file functions and shell loops β€” they are easy to miss

Step 4 β€” Narrow globs and move heavy checks out Jump to heading

Globs that match more than necessary waste time: * runs a tool on images and lockfiles it will ignore anyway. And checks whose cost does not depend on the number of staged files β€” full type checks, test suites β€” belong in pre-push or CI.

export default {
  "*.{ts,tsx}": ["eslint --cache --fix", "prettier --write --cache"],
  "*.{json,md,yml}": "prettier --write --cache",
  // removed: "*": "prettier --write" β€” matched binaries and lockfiles
  // moved to .husky/pre-push: "tsc --noEmit"
};
Placing checks by costPer-file formatters and linters with caches are cheap and belong in pre-commit. Project-wide checks such as type checks and affected tests belong in pre-push, once per push. Full test suites and builds belong in CI, where they are required.cheapest checks run most oftenpre-commitformatters, cached linters β€” secondspre-pushtype check, affected tests β€” tens of secondsCI (required)full suite, build β€” minuteseach check runs at the latest point where it is still cheap to fix

Step 5 β€” Keep it fast as the repository grows Jump to heading

Speed regressions creep in as tools are added. Record the hook’s time in a cheap way and look at it occasionally.

# .husky/pre-commit β€” log duration locally, never block on it
start=$(date +%s)
npx lint-staged
status=$?
echo "$(date +%F) $(( $(date +%s) - start ))s" >> "$(git rev-parse --git-dir)/hook-timings.log"
exit $status
# How has it trended?
tail -20 "$(git rev-parse --git-dir)/hook-timings.log" | awk '{s+=$2; n++} END {print "avg:", s/n "s"}'

If the average creeps past five seconds, repeat step 1. The goal is a hook nobody has a reason to skip.

Step 6 β€” Warm caches after switching branches Jump to heading

Caches keyed on file content survive branch switches, but some tools invalidate their whole cache when configuration changes β€” and switching between branches with different lint configuration does exactly that. The first commit after a switch is then slow again. A post-checkout hook can warm the cache in the background so the next commit is fast.

# .husky/post-checkout β€” refresh lint caches in the background after a branch switch
[ "$3" = "1" ] || exit 0                         # only for branch checkouts, not file checkouts
( npx eslint --cache --cache-location node_modules/.cache/eslint/ src/ >/dev/null 2>&1 & ) 

Background work in hooks must never block or fail the operation that triggered it, which is why the command runs detached and its output is discarded. If warming the cache takes longer than the time to the next commit, the commit simply uses whatever is cached by then.

Validation checklist Jump to heading

Frequently Asked Questions Jump to heading

Is it worth running tasks in parallel? Jump to heading

lint-staged already runs different globs’ tasks concurrently. Within a glob, tasks run in sequence because a formatter and a linter on the same file must not race. Parallelism inside a tool, such as ESLint’s own concurrency options, can help on large commits.

Should the hook ever run tests? Jump to heading

Only very fast, targeted ones β€” tests for the staged files, finishing in a second or two. Anything broader belongs in pre-push, as in running a fast test subset in a pre-push hook.

What about huge commits, such as a mass rename? Jump to heading

Large staged sets will be slow whatever you do. lint-staged can split long file lists into chunks; for one-off mass changes, committing with --no-verify and relying on CI is reasonable, as long as it is the exception.