Speed Up fWith lint-staged

Published · 6 views
Speed Up fWith lint-staged

Pre-commit hooks that take fifteen seconds to run are the fastest way to get a team quietly typing git commit --no-verify out of habit. I watched this happen on a client's Laravel/React monorepo last year — not because ESLint or Prettier were slow tools, but because the hook was linting and formatting the entire repository on every single commit, instead of just the handful of files someone actually touched. By the third week, half the team had the --no-verify flag muscle-memoried in, and the linter was effectively dead code.

That's the real failure mode of pre-commit hooks, and it's almost always fixable with lint-staged instead of a blanket npm run lint. Here's the naive setup most tutorials show you, the actual fix, and why the fix matters more than it looks like it should.

The naive setup (what most tutorials show you)

Husky makes it trivially easy to wire a script into .git/hooks/pre-commit, so most getting-started guides point you straight at running your existing lint/format scripts on every commit:

# .husky/pre-commit
#!/usr/bin/env sh
npm run lint
npm run format:check

That works fine on day one, when the repo has forty files. It falls apart once the repo has four thousand. npm run lint under the hood is usually eslint ., which walks the entire project tree regardless of what you actually staged. Committing a one-line typo fix in a README now costs you the same lint pass as a thousand-file refactor. On a monorepo I worked on with a Next.js frontend and a Laravel API in the same repo, this hook alone stretched from under a second to over twenty — and that's before Prettier runs its own full pass behind it.

The actual fix: scope checks to staged files with lint-staged

lint-staged runs your linters and formatters only against the files that are actually staged for the commit — which is almost always what you wanted in the first place. Install it alongside Husky:

npm install --save-dev lint-staged

Then replace the hook body and add a config:

// package.json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,scss}": ["prettier --write"],
    "*.php": ["./vendor/bin/pint"]
  }
}
# .husky/pre-commit
#!/usr/bin/env sh
npx lint-staged

That's the whole fix. lint-staged matches your staged files against the glob patterns, runs only the relevant commands against only those files, and — critically — re-stages the files after --fix/--write touch them, so the fixed version is what actually gets committed. On that same monorepo, swapping to this setup took the hook from twenty-plus seconds down to under a second for a typical two-or-three-file commit, because it was no longer re-linting four thousand files to check three of them.

Tuning it for a real-sized repo

Once the basic swap is in, a couple of things are worth getting right rather than leaving on defaults:

  • Split your glob patterns by language/toolchain, like the PHP line above — don't try to run one linter against every file type through a single catch-all pattern.
  • Put the slowest check last, not first. lint-staged runs tasks for a given file sequentially by default, and if prettier --write fixes a formatting issue that would've failed a stricter check, you want that fix applied before the stricter check runs, not after it's already failed the commit.
  • Set concurrent: false in rare cases where two tasks touch overlapping files and step on each other's writes — by default lint-staged runs independent glob groups in parallel, which is almost always what you want, but I've hit one occasion where a Babel transform and a formatter raced on the same file and corrupted it. If you ever see a staged file come out mangled after a commit, this is the first thing to check.
  • Don't lint test fixtures or generated files — add them to .eslintignore/.prettierignore rather than trying to exclude them in the glob pattern itself, which gets unreadable fast.

I'd skip reaching for husky run hooks that shell out to a full test suite on every commit, too — that's a different problem people try to solve with the same hammer. Tests belong in a pre-push hook or CI, not blocking every single commit; a hook that takes more than two or three seconds stops being a safety net and starts being something people route around.

When lint-staged isn't enough

On a genuinely large monorepo — the kind managed with Nx or Turborepo — even lint-staged scoped to staged files can start to drag, because ESLint's own startup cost (parsing your config, resolving plugins) gets paid per invocation. At that scale I'd reach for your monorepo tool's own "affected" graph instead (nx affected --target=lint or turbo run lint --filter=...[HEAD^]) rather than fighting lint-staged's glob matching to get there. Most teams don't need this — if your lint step is under two seconds with plain lint-staged, leave it alone. Reaching for the affected-graph tooling before you actually need it just adds another layer of config to maintain for no real speed gain.

Key takeaways

Pre-commit hooks earn their place in a workflow by being fast enough that nobody thinks about bypassing them. Here's what actually matters, in order: scope every check to staged files with lint-staged instead of running your full lint/format scripts on every commit, put the auto-fixing tasks before the strict checks in your config, and keep tests out of the commit hook entirely. Do just the first of these and most repos will already feel fast again.

#developer-productivity #git #husky #lint-staged #eslint #pre-commit-hooks
Aliyan Faisal

Written by

Aliyan Faisal

Software engineer and AI developer. I build LLM-powered products, RAG pipelines and automations, and the web apps behind them.

0 Comments

No comments yet — be the first to share your thoughts.

Leave a comment

Never published.