ArganoのWanです。 この記事では、Gitのプリコミット(pre-commit)フックを高速化するために、Go製のLefthookとRust製のBiomeを導入し、Next.jsリポジトリを用いてベンチマークを測定・比較した結果について紹介します。


Long pre-commit waits often lead teams to disable hooks or bypass them entirely. Slow lint steps add noticeable latency to each commit.

Yet, keeping these checks active remains crucial because they keep commits lint-clean and help teams maintain consistent code quality.

To quantify latency improvements, we benchmarked compiled toolchains (written in Rust and Go) against a standard Node.js-based setup. We used Vercel’s Next.js repository as the test workspace because it already integrates ESLint and Husky. The goal was to evaluate whether native tooling can make local Git hook execution less intrusive in daily development.

Why I Evaluated This

In a new client project at Argano, the team was already using Lefthook. That got me interested in comparing the experience with Husky.

When I shared this topic with teammates, a colleague mentioned that Husky often feels slow when committing changes in their project. This feedback was the main motivation to run these benchmarks.

Methodology & Setup

All benchmarks were run on a MacBook Pro with an Apple M4 Pro chip and 48GB of unified memory.

Environment & Tool Versions

  • Node.js v22.15.0
  • pnpm v11.13.1
  • Next.js vercel/next.js (commit 3ff5b7c8b392401a262f9350b559147bd274cc91)
  • ESLint v9.37.0
  • Prettier v3.6.2
  • Biome v2.5.10
  • Lefthook v2.1.11
  • hyperfine v1.20.0

We focus on process startup overhead. Since Git hooks run as one-off commands, startup overhead is paid on every single commit. To keep the comparison fair, every git commit run started with the exact same staged change. For the benchmark loops, each commit was rolled back using git reset --soft HEAD~1 before the next iteration. All hyperfine measurements use default settings with no custom warmup or run-count overrides.

Note: After the first iteration, the staged file is already formatted, so subsequent runs measure the steady-state path where no formatting fixes are written.


1. ESLint Baseline

First, we timed ESLint on a single Next.js file (packages/next/src/server/next.ts):

# Measuring raw ESLint execution time
hyperfine 'pnpm eslint packages/next/src/server/next.ts' -i
# Output: 850.9 ms ± 392.5 ms

ESLint requires 850.9 ms to analyze a single file, mostly due to Node.js startup costs.


2. Transitioning to Biome

Next, we run Biome on the same file to measure the runtime difference:

# Measuring Biome execution time (via pnpm)
hyperfine 'pnpm biome check packages/next/src/server/next.ts' -i
# Output: 160.4 ms ± 1.9 ms

# Measuring Biome execution time (direct binary execution)
hyperfine './node_modules/.bin/biome check packages/next/src/server/next.ts' -i
# Output: 42.3 ms ± 0.6 ms

Biome finishes in 160.4 ms via pnpm or 42.3 ms directly. This is significantly faster due to the compiled native binary.


3. Full-Project Benchmarks

To see how both tools scale, we timed a full check over packages/next/src/:

# ESLint full-directory check
hyperfine 'pnpm eslint packages/next/src' -i
# Output: 12.804 s ± 0.240 s

# Biome full-directory check (via pnpm)
hyperfine 'pnpm biome check packages/next/src' -i
# Output: 2.221 s ± 0.071 s

# Biome full-directory check (direct binary execution)
hyperfine './node_modules/.bin/biome check packages/next/src' -i
# Output: 2.112 s ± 0.049 s

Biome completes the check in about 2 seconds, while ESLint takes 12.8 seconds.

Note that ESLint runs the default Next.js rules, while Biome runs its default recommended ruleset. Since their rulesets are not identical, this compares two realistic setups rather than identical configurations. Additionally, Biome check includes formatting, which ESLint alone does not.


4. Husky with ESLint Baseline

We benchmarked an end-to-end git commit on a single modified staged file. As a reference, a commit with no hooks active (git commit --no-verify) was timed as our baseline:

# Measuring raw commit time with no hooks
hyperfine --prepare 'git reset --soft HEAD~1' 'git commit --no-verify -m "benchmark"'
# Output: 76.7 ms ± 3.1 ms

Next, we timed Husky with lint-staged running ESLint and Prettier:

# Timing Husky pre-commit hook
hyperfine --prepare 'git reset --soft HEAD~1' 'git commit -m "benchmark"'
# Output: 2.106 s ± 0.048 s

The standard setup takes over 2 seconds per commit.


5. Husky with Biome

We can replace ESLint and Prettier inside lint-staged with Biome. To configure this, define lint-staged-biome.config.cjs:

module.exports = {
  '*.{js,jsx,mjs,ts,tsx,mts}': ['biome check --write'],
}

Then, update your Husky pre-commit hook to execute lint-staged with this configuration:

pnpm lint-staged --config lint-staged-biome.config.cjs

Timing this updated setup:

# Timing Husky pre-commit hook with Biome
hyperfine --prepare 'git reset --soft HEAD~1' 'git commit -m "benchmark"'
# Output: 843.6 ms ± 13.9 ms

Transitioning to Biome drops the commit time to 843.6 ms, though Node.js startup overhead remains.


6. Biome with Lefthook

To bypass the Node.js startup floor, we can transition to Lefthook.

To configure Lefthook:

  1. Deactivate Husky and remove the husky prepare script from package.json:
    git config --unset core.hooksPath
    pnpm remove husky
    
  2. Install Lefthook:
    pnpm add -Dw lefthook
    
  3. Register Lefthook’s hooks:
    pnpm lefthook install
    
  4. Define the pre-commit action in lefthook.yml. Here, we specify stage_fixed: true to match Husky’s default behavior:
pre-commit:
  commands:
    biome:
      glob: '*.{js,jsx,mjs,ts,tsx,mts}'
      run: ./node_modules/.bin/biome check --write {staged_files}
      stage_fixed: true

We ran the end-to-end git commit benchmark:

# Timing Lefthook pre-commit hook with Biome
hyperfine --prepare 'git reset --soft HEAD~1' 'git commit -m "benchmark"'
# Output: 445.4 ms ± 53.1 ms

Since Lefthook runs as a native Go binary, avoiding Node.js startup, the commit time drops to 445.4 ms. This is 4.7x faster than Husky.


Quantitative Comparison

Local Git Pre-Commit Hook (Single Staged File via Git Commit)

Below is the measured end-to-end execution time of git commit when committing a single modified staged file:

Git Hook ManagerLinter & FormatterTotal Commit TimePure Hook OverheadSpeedup (Overhead)
NoneNone76.7 ms ± 3.1 ms0 ms- (Hookless)
HuskyESLint + Prettier2,106.0 ms2,029.3 msBaseline
HuskyBiome843.6 ms766.9 ms2.6x
LefthookBiome445.4 ms368.7 ms5.5x

Note: Pure Hook Overhead = Total Commit Time - Baseline Commit Time (76.7 ms).

CI/CD Pipeline Scan (Full-Project Directory Check)

Below is the execution time for running a complete check over all files in the packages/next/src directory:

ToolchainCommandTotal Execution TimeRelative Speedup
Node.js / JSpnpm eslint12,804 msBaseline
Rust Native (via pnpm)pnpm biome check2,221 ms5.8x
Rust Native (direct)./node_modules/.bin/biome check2,112 ms6.1x

Conclusion

Replacing Node.js-based tools with native Go and Rust alternatives significantly reduces pre-commit latency and project-wide check times. The pure wait time caused by the hook setup itself dropped from 2,029 ms to 369 ms.

Even replacing ESLint with Biome alone produces meaningful speed gains. It also speeds up CI pipelines, especially where linting is a bottleneck in the pipeline.

However, there are trade-offs to consider. Biome does not support the ecosystem of custom ESLint plugins, nor can it execute type-aware lint rules. For projects with heavy ESLint plugin usage, the migration costs may outweigh the performance gains. For new repositories or codebases where linting speed is a pain point, the performance payoff is high.

Going forward, Biome is a compelling option when linting performance becomes a bottleneck. Lefthook is also well worth exploring if pre-commit hooks slow down development. Evaluating the migration costs beforehand remains key.


References