Biome is a Rust-based linter and formatter that replaces both ESLint and Prettier with a single binary-backed tool, run through one config file instead of two. The speed claim around it gets repeated constantly. It holds up, but it's not the interesting question for most teams. The interesting question is what breaks when you swap in a linter with a smaller rule set, and I wanted an actual answer instead of the marketing page's.
What Do the Benchmark Numbers Show?
I ran both toolchains against the same 40 TypeScript files (13,810 lines), copied from this monorepo's shared library, on an Apple M1 Mac with Node 22.23.2. ESLint was 9.39.5 with the @eslint/js and typescript-eslint recommended presets (no type-aware rules), Prettier was 3.9.9 with two options set, and Biome was 2.5.14 running biome check, which lints and formats in one pass. Every figure is the median of seven runs, calling the binaries directly so npx startup doesn't pollute the timing.
One caveat before the table: ESLint 10.0.0 shipped on 2026-02-06 and 10.12.0 is the current release, so the ESLint column below is the 9.x maintenance line, not today's major. The setup carries over. ESLint 10 reads only flat config (eslint.config.*), which is the format I used, and the latest typescript-eslint (8.71.1) and eslint-plugin-react-hooks (7.1.1) both list ^10.0.0 in their ESLint peer ranges. I haven't timed ESLint 10, so I won't claim the gap moved either way.
| ESLint 9.39.5 + Prettier 3.9.9 | Biome 2.5.14 | |
|---|---|---|
| Lint + format, cold (no cache files) | 1.86s | 0.20s |
Lint + format, second run (--cache on both ESLint and Prettier) | 1.28s | 0.20s |
| Config files needed | 2 (eslint.config.js, .prettierrc) | 1 (biome.json) |
node_modules on disk | 56.5MB | 56.4MB |
Packages in npm ls --all --parseable | 110 | 3 |
The gap is about 9x cold and 6x on the second run. That's a real difference, and it's smaller than the double-digit multiples you'll see quoted. I only measured this one size, so I wouldn't extrapolate it to a 2,000-file monorepo without running it there. Cold cache means no .eslintcache and no Prettier cache on disk, which is what most CI runners start from on every job. Note that Prettier's own cache barely helped: its check went from 0.82s to 0.88s between runs, so the warm-run cost is mostly Prettier.
The install-size row surprised me. I'd assumed the Rust binary would be the small side. On macOS arm64 it isn't: the platform binary alone accounts for nearly all of Biome's 56MB, roughly what ESLint, typescript-eslint, Prettier and the TypeScript package add up to (du -sk on both installs). What differs is the package count, 110 against 3, and fewer packages means fewer version-compatibility pins to keep patched over time.
Install size turned out to be a wash at about 56MB each way; the difference is 110 packages to keep compatible against 3, and that is where a single-binary linter earns its keep.
How Do You Set Up Biome on an Existing Project?
npx @biomejs/biome init
npx @biomejs/biome migrate eslint --write
npx @biomejs/biome migrate prettier --write
The migrate commands read your existing ESLint config (Biome's guide says it handles both legacy and flat files and follows extends) and .prettierrc, and generate a biome.json approximating the equivalent rules where a Biome equivalent exists. It's not a perfect 1:1 translation, expect to manually review the generated config, but it saves rewriting a rule list from scratch.
Here's the order I'd follow, and the one I used on the 40-file sample:
- Run
biome initfirst to get a baseline config before touching anything else. - Run
biome migrate eslint --writeand diff the generated rule list against your original.eslintrcline by line. - Run
biome migrate prettier --writelast, since formatting conflicts are the easiest to spot and the cheapest to fix if the migration gets something wrong.
// biome.json (generated + reviewed)
{
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUnusedVariables": "error"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}
On 2.5.14, biome init writes "preset": "recommended". The older "recommended": true still lints, but biome migrate flags it as needing an update, so a config copied from an older tutorial works and nags. After biome migrate prettier --write, I ran biome format over files Prettier had just formatted, and it flagged 4 of the 40. All four were long union types, which Biome breaks one member per line where Prettier keeps them on a single line. Small, but it means a formatter swap produces a real diff on first run.
Where Does the Rule Gap Actually Show Up?
Biome's migration report is the fastest honest answer to the coverage question, so I ran it against the benchmark's ESLint config (js.configs.recommended plus typescript-eslint recommended). It printed 88 ESLint rules found and 74 migrated directly. Seven more map to Biome rules it calls "inspired", which --include-inspired switches on, and that's where the 81 rules (92%) reported as covered comes from. Rule coverage is the share of your active ESLint rules that have a functionally equivalent Biome rule, and it matters far more than raw speed once a migration starts, because a fast linter that silently drops seven enforced rules just moved the risk somewhere less visible.
The remaining seven are listed as not yet implemented: no-delete-var, no-invalid-regexp, no-new-symbol, no-octal, no-unexpected-multiline, @typescript-eslint/no-unused-expressions and @typescript-eslint/triple-slash-reference. On a typical app that list is mostly trivia, but no-unused-expressions is the kind of rule people notice missing.
The one I expected to find missing was eslint-plugin-react-hooks. It isn't. I wrote a component with a stale useEffect dependency array and a conditional useState call, then ran both tools over it. ESLint (with eslint-plugin-react-hooks 7.1.1) reported react-hooks/exhaustive-deps on the missing step dependency and react-hooks/rules-of-hooks on the conditional call. Biome reported useExhaustiveDependencies on the same effect and useHookAtTopLevel on the same call, with no plugin installed. Both rules switch on when Biome sees react in the project, and biome explain useExhaustiveDependencies lists the React domain. Whether the two agree on every edge case, custom hooks or unusual dependency shapes for instance, I haven't tested. Run both on your own code before you trust the match.
The gaps that are left, in the order I'd check them:
- The seven unimplemented rules above. Read the list against what your config actually enables.
- Type-aware typescript-eslint rules. Biome does ship
noFloatingPromises,noMisusedPromisesanduseAwaitThenable, but its docs still list all three in the experimental nursery group, and they belong to thetypesdomain. Enabling that domain makes Biome scan the whole project and run its own type inference engine, which the docs say has a performance cost, so the speed table above doesn't cover it. The typescript-eslint ruleno-unnecessary-conditionhas a Biome counterpart under a plural name,noUnnecessaryConditions(stable, added in 2.1.4).biome explain noUnnecessaryConditionandbiome explain noUnnecessaryTypeAssertionboth returned "Unrecognized option" on 2.5.14, and I found no docs page for a type-assertion rule, sono-unnecessary-type-assertionis the one I'd treat as a gap. - Custom in-house ESLint plugins. A project-specific plugin enforcing an internal convention has no automatic migration path. Biome does have its own plugin mechanism now: plugins are GritQL (
.grit) files listed underpluginsinbiome.json, they target JavaScript, CSS and JSON, and since 2.5 they can attach code fixes. I haven't tried it. It's a pattern-matching language rather than ESLint's visitor API, so budget a rewrite of each custom rule, or keep ESLint for that one job.
None of these are hard blockers. They're specific things to check against your actual config, not the marketing page's rule-count comparison.
Biome's own migrate report mapped 81 of 88 ESLint rules on a stock typescript-eslint config, and it ships both eslint-plugin-react-hooks rules, so the real gap is a short unimplemented list, type-aware checks and your custom plugins.
Can You Use a Hybrid Approach: Biome for Formatting, ESLint for the Gap?
// package.json scripts
{
"scripts": {
"format": "biome format --write .",
"lint:biome": "biome lint .",
"lint:eslint": "eslint ."
}
}
Here eslint.config.js shrinks to the rules that didn't make it into the generated biome.json, plus any custom plugin. It's not the cleanest long-term architecture, but it's a sensible migration path: take the formatting speed win immediately, take most of the linting speed win, and keep a narrow ESLint invocation for what Biome can't do yet rather than blocking the whole migration on 100 percent rule parity. I haven't run that hybrid long enough to tell you whether teams keep it or finish the cutover, so treat it as a first step rather than a verdict.
So Should You Switch to Biome?
Biome's speed is real and reproducible: about 9x cold on my sample, less once ESLint's cache is warm. Speed isn't the decision point, though. The decision is whether your specific rule set has Biome equivalents, and the tool will tell you in one command. Run biome migrate eslint, diff the biome.json it writes against your ESLint config (the guide says it sets the preset to none and enables only what it migrated, so whatever is missing is your gap), and decide from that rather than from the speed claim alone.
One more thing in Biome's favour that never shows up in a speed table: a linter and formatter lives at the build boundary, so its exit cost is close to nothing. Config files change, source files don't. That's the opposite of a runtime dependency whose API ends up in 900 call sites, and it's why switching linters is a Tuesday afternoon rather than a quarter.