Biome vs ESLint + Prettier: The All-in-One Linter vs the Established Duo
One binary and one config against six packages and a decade of plugins. The gap is real but narrower than the write-ups claim — Biome has had React Hooks rules since v1.0.0, and its v2 config drops the field most tutorials still tell you to set.
Biome vs ESLint + Prettier: The All-in-One Linter vs the Established Duo
You're setting up a new Next.js project. You reach for the familiar stack: npm install -D eslint prettier eslint-config-prettier eslint-plugin-react eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser. That's six packages before you've written a single rule. Then the config — .eslintrc.json, .prettierrc, .prettierignore, parser options, plugin registration, extends arrays. Forty-five minutes later, lint works.
Or you run npm install -D @biomejs/biome, create one config file, and you're done in two minutes.
That's the pitch for Biome. It's a Rust-powered linter and formatter that replaces both ESLint and Prettier with a single binary. Biome v2 brought type-aware rules that don't shell out to tsc, cross-file analysis, and a first iteration of linter plugins; by 2.5 the plugins can also apply fixes. So the question isn't "is Biome ready?" It's "is switching worth it for your specific situation?" — and the answer turns on a much narrower set of gaps than the usual write-ups claim.
The Quick Decision Matrix
| Biome | ESLint + Prettier | |
|---|---|---|
| Install | 1 package | 5–10 packages |
| Config files | 1 (biome.json) | 2+ (.eslintrc, .prettierrc) |
| Performance | Rust, multi-threaded | Node.js, single-threaded |
| Type-aware linting | Partial (no tsc) | Full (via typescript-eslint) |
| React Hooks rules | Yes (useExhaustiveDependencies, useHookAtTopLevel) | Yes (eslint-plugin-react-hooks) |
| Plugin ecosystem | Small; GritQL plugins since v2 | Massive |
| Custom rules | GritQL plugins (fixes since 2.5) | Extensive |
| CSS/GraphQL | Yes (v2) | Via plugins |
| Migration tool | biome migrate eslint | N/A |
Setup: One Config to Rule Them All
The ESLint + Prettier setup for a modern TypeScript/React project looks like this:
npm install -D eslint prettier eslint-config-prettier \
@typescript-eslint/eslint-plugin @typescript-eslint/parser \
eslint-plugin-react eslint-plugin-react-hooks// eslint.config.mjs — flat config; ESLint 9 no longer reads .eslintrc by default
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import prettier from 'eslint-config-prettier'
export default [
js.configs.recommended,
...tseslint.configs.recommended,
react.configs.flat.recommended,
reactHooks.configs.flat['recommended-latest'],
prettier,
{
settings: { react: { version: 'detect' } },
rules: {
'@typescript-eslint/no-explicit-any': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
]If the version of this you have in your head is .eslintrc.json with extends: ["plugin:..."] strings, that's the legacy format. Flat config has been the default since ESLint 9, and the string-based plugin: extends don't exist in it. Note the .flat in the react-hooks line, too — on eslint-plugin-react-hooks 7 the bare configs['recommended-latest'] is still the eslintrc-shaped object, and feeding it to flat config fails with "A config object has a plugins key defined as an array of strings." The flat variants live under configs.flat.
// .prettierrc
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 100
}Now add a .prettierignore for generated files, wire up lint-staged to run both on commit, update your CI to run them as separate steps... you know how this goes.
Biome flips this entirely:
npm install -D @biomejs/biome
npx @biomejs/biome init// biome.json
{
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}One file. No plugin installation. Import sorting is built in. Run biome check --write . and it lints, formats, and fixes everything in one pass.
Performance: The Number That Ends Arguments
Biome is written in Rust and processes files in parallel from the start. ESLint and Prettier are both single-threaded Node.js processes. Every speedup multiplier you will find quoted for this — including the ones in the tables that circulate — traces back to somebody's unlabelled run on unnamed hardware, so here is the only version worth acting on:
# on your own repo, with caches cold
hyperfine --warmup 1 'npx biome check .' 'npx eslint . && npx prettier --check .'That takes a minute and produces a number that is actually about your codebase. The shape of the result is predictable — Biome wins, and wins by more the more files you have — but the magnitude depends on your rule set, whether you have -type-checked rules enabled, and how much of your tree is ignored.
Two things worth knowing before you go looking for the win. For a project under a few hundred files, both feel instant and this is not the reason to switch. And the gap is widest exactly where it's least visible: CI cold starts, where Node.js process startup and module loading are paid on every run.
The performance gap is largest in CI cold-start conditions where Node.js startup overhead compounds. In watch mode with long-running ESLint daemons (via eslint_d or the VS Code extension), the gap narrows considerably.
Architecture: How They Actually Work
Understanding the design difference explains the tradeoffs.
ESLint's plugin model is its greatest strength and biggest performance liability. Each plugin adds a Node.js module that hooks into the AST traversal. Type-aware rules actually spawn a TypeScript compiler process to get full type information — which is why @typescript-eslint/recommended-type-checked rules make ESLint slow.
Biome handles everything in a single process, parsing files once and running all checks in parallel. Its type inference scans .d.ts files in node_modules without running tsc, which keeps it fast but explains why coverage isn't 100%.
Type-Aware Linting: The Honest Gap
This is the most important thing to understand before switching.
ESLint with typescript-eslint can catch things like floating promises, unsafe assignments, and type boundary violations because it runs the TypeScript compiler and has full type information:
// typescript-eslint catches this — eslint-plugin-react-hooks does too
async function fetchUser(id: string) {
const user = await db.query(id);
return user;
}
// Caller — typescript-eslint/no-floating-promises would catch this
fetchUser("123"); // Promise not awaited — caught with full type infoBiome v2 introduced type inference that scans .d.ts files instead of running tsc. That is the whole trade in one sentence: you get most of the value of type-aware rules at a fraction of the cost, and you give up the cases that need the real type checker. Biome is explicit that its inference is incomplete rather than equivalent.
I'm not going to put a coverage percentage on it, because nobody has published one and a made-up number here is worse than no number. The way to find out is to run both against your own code: enable noFloatingPromises in Biome and @typescript-eslint/no-floating-promises in ESLint on the same tree and diff the findings. If the diff is empty or boring, you have your answer. If you're writing a payments path where an unawaited promise is a real incident, do that diff before you drop the tsc-backed rules.
ESLint — these require a real TypeScript program (projectService / parserOptions.project):
@typescript-eslint/no-floating-promises
@typescript-eslint/no-unsafe-assignment
@typescript-eslint/strict-boolean-expressions
Biome — inferred from .d.ts, no tsc process:
nursery/noFloatingPromisesThe practical advice: if you're running recommended-type-checked from typescript-eslint and you care about every case it catches, Biome isn't a full drop-in yet. If you're running recommended (not type-checked), Biome covers it.
The ESLint Plugin Ecosystem Problem
ESLint's plugin ecosystem is 10 years old. There are plugins for everything:
eslint-plugin-react-hooks— exhaustive-deps and rules-of-hookseslint-plugin-jest/eslint-plugin-vitesteslint-plugin-import— module resolution ruleseslint-plugin-security— common security antipatternseslint-plugin-unicorn— opinionated style rules- Framework-specific: Next.js, Vue, Svelte, Angular
Biome has GritQL plugins as of v2 — and code fixes in them as of 2.5 — but the third-party ecosystem around it is small, and that is a real difference.
What is not a gap, despite being the most repeated claim in this comparison: React Hooks rules. Biome ships useExhaustiveDependencies and useHookAtTopLevel, both available since v1.0.0, both error by default, and the docs list the first as "Same as react-hooks/exhaustive-deps". I checked rather than assumed — Biome 2.3.0 against a useEffect missing a dependency:
$ biome lint hook.tsx
hook.tsx:4:3 lint/correctness/useExhaustiveDependencies FIXABLE
× This hook does not specify its dependency on id.
> 4 │ useEffect(() => {
i Unsafe fix: Add the missing dependency to the list.
6 │ ··},·[id]);One catch that explains why people conclude the rule is missing: it belongs to Biome's react domain, and on a project where Biome can't see React in your dependencies it stays quiet. Turn it on explicitly and it fires:
// biome.json
{
"linter": {
"domains": { "react": "recommended" }
}
}The gaps that are real are elsewhere: framework configs like next/core-web-vitals, and the long tail of eslint-plugin-import, eslint-plugin-security and eslint-plugin-unicorn rules with no Biome equivalent.
Migration: The biome migrate Command
If you have an existing ESLint config, Biome has a migration tool:
npx @biomejs/biome migrate eslint --writeThis reads your existing ESLint config and maps rules to their Biome equivalents in biome.json, reporting the ones it can't map. Rather than trusting a coverage percentage from anyone — including this article — run it on a branch and read the report: that list is your migration decision, and it's specific to your config in a way no general figure can be.
biome migrate also handles the v1-to-v2 config moves, which matters if you're following an older tutorial. The most common one: the top-level organizeImports field is gone in v2. It's now an assist action, and Biome will reject the old key outright:
$ biome check .
biome.json:3:3 deserialize ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
× Found an unknown key `organizeImports`.Then you remove the old packages:
npm uninstall eslint prettier eslint-config-prettier \
@typescript-eslint/eslint-plugin @typescript-eslint/parser \
eslint-plugin-react eslint-plugin-react-hooksAnd delete .eslintrc.json, .prettierrc, .prettierignore. Removing seven config-related files is genuinely satisfying.
CI Integration: The Setup That Actually Matters
Both tools work fine in CI. Biome's single-command check is simpler to configure:
# GitHub Actions — Biome
- name: Lint & Format Check
run: npx @biomejs/biome ci .The biome ci command (not biome check) exits with a non-zero code on any issue and doesn't modify files — exactly what you want in CI. ESLint requires separate steps for linting and format checking:
# GitHub Actions — ESLint + Prettier
- name: Lint
run: npx eslint . # no --ext: it was removed in flat config
- name: Format Check
run: npx prettier --check .The difference in CI time between the two isn't just the tool runtime — it's also the install. Count it on your own lockfile (npm ls --all --parseable | wc -l before and after) rather than trusting a round number; the point is the order of magnitude, and it favours the single binary.
Add biome.json to your editor's format-on-save config via the Biome VS Code extension (biomejs.biome). It's the same extension approach as the Prettier extension, but one extension instead of two, with no need to tell the editor which formatter to use for which file type.
When to Use Biome
New project, TypeScript, no framework-specific linting requirements. You want a minimal toolchain that can format and lint in a single pass. Your team finds ESLint config maintenance tedious (everyone does). You're already using Prettier only for formatting and ESLint only for rules — Biome does both better in most cases. You care about pre-commit hook speed. You're on a monorepo and linting is a meaningful CI cost.
When to Stick With ESLint + Prettier
You use type-checked rules from typescript-eslint (the -type-checked variants) and the full tsc-backed coverage matters to you. You have custom ESLint rules your team wrote. You use framework-specific plugins without Biome equivalents — next/core-web-vitals, eslint-plugin-jest, eslint-plugin-security. You're on a large existing project and the migration risk outweighs the config simplification.
When to Use Both
More common than you'd think, and note that hooks rules are not the reason — Biome covers those. The split that makes sense is Biome for formatting and the bulk of linting (JS/TS quality rules, import sorting, unused variables) and a deliberately tiny ESLint config holding only the plugin rules with no Biome equivalent: next/core-web-vitals, a couple of import/* rules, whatever your team actually depends on.
// package.json
{
"scripts": {
"check": "biome check . && eslint src/ --no-config-lookup --config eslint.gaps.mjs"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["biome check --write --no-errors-on-unmatched"],
"*.{json,css,md}": ["biome format --write"]
}
}This isn't the clean single-tool story Biome promises, but it's faster than pure ESLint + Prettier and simpler than a full ESLint config.
The Real Question: New Projects vs Existing Projects
For new projects: use Biome. The friction of a missing plugin or two is much lower when you never had those plugins configured. You're not migrating anything, you're just making a choice.
For existing projects: depends on your ESLint config. Run the migration command and look at what it can't migrate. If the unmigrated rules are things you don't actually care about catching, the migration takes an afternoon. If they're core to how your team works, schedule it for later.
The tipping point where teams switch is usually "I am so tired of resolving ESLint peer dependency conflicts after a Next.js upgrade." That moment hits differently on a Thursday afternoon.
The comparison is ultimately about where you sit on the "breadth vs speed" spectrum. ESLint + Prettier give you a decade of accumulated rules and plugins, at the cost of config complexity and slow CI. Biome gives you a faster, simpler toolchain that covers the majority of what most projects actually need — with the gaps narrowing every release. For most new TypeScript projects in 2026, Biome is the right default, with a note to run biome migrate eslint on a branch and read its unmapped-rule list before you commit to a migration. That list is the honest version of this whole comparison.
Comments (0)
No comments yet. Be the first to share your thoughts!