DevLift
Back to Blog

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.

Admin
June 3, 20269 min read0 views

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

BiomeESLint + Prettier
Install1 package5–10 packages
Config files1 (biome.json)2+ (.eslintrc, .prettierrc)
PerformanceRust, multi-threadedNode.js, single-threaded
Type-aware lintingPartial (no tsc)Full (via typescript-eslint)
React Hooks rulesYes (useExhaustiveDependencies, useHookAtTopLevel)Yes (eslint-plugin-react-hooks)
Plugin ecosystemSmall; GritQL plugins since v2Massive
Custom rulesGritQL plugins (fixes since 2.5)Extensive
CSS/GraphQLYes (v2)Via plugins
Migration toolbiome migrate eslintN/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.

Rendering diagram...

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 info

Biome 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/noFloatingPromises

The 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-hooks
  • eslint-plugin-jest / eslint-plugin-vitest
  • eslint-plugin-import — module resolution rules
  • eslint-plugin-security — common security antipatterns
  • eslint-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 --write

This 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-hooks

And 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!

Related Articles

The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read
Reading a 2 GB file all at once doesn't run out of memory — it hits V8's 512 MB string limit first. Streams process it in flat memory instead. Here's the pipeline pattern, custom Transform streams, and backpressure, with the numbers measured on Node 22.
AdminAugust 3, 20266 min read