Skip to content

Biome vs ESLint in 2026: The Linter Switch I Kept Putting Off

Biome vs ESLint in 2026: The Linter Switch I Kept Putting Off

I’d been promising myself to “look at Biome this weekend” for about ten weekends in a row. Last month I finally did it on a real Next.js project and the migration took less than an hour. ESLint and Prettier had spent four years quietly growing in my repos, and I kept telling myself the config was fine, the speed was fine, everything was fine.

Reader, it was not fine.

This is the honest write-up I wanted to read before I did the switch. What broke, what didn’t, the rough numbers from my own machine, and the parts where I’m still keeping ESLint around for one specific reason. If you’re a TypeScript developer who’s been hearing “just switch to Biome” for months and waving it away, this one’s for me a year ago.

Why I held out on ESLint for so long

ESLint is the lingua franca. That’s the honest reason. Every plugin I cared about, eslint-plugin-react, eslint-plugin-import, eslint-plugin-jsx-a11y, the TypeScript-ESLint stack, assumed ESLint’s rule API. The big rewrites in ESLint 9 with flat config actually fixed my biggest complaint, which was the legacy .eslintrc.json cascade mess. Once I migrated to flat config, the urge to look elsewhere quietly dropped to zero.

What changed my mind was a new TypeScript monorepo at work where I had three packages and zero patience. The lint step was running for thirty-six seconds on my laptop before a single test had executed. The CI pipeline spent more time linting than running unit tests. I’d had enough.

I’d also been burned by Prettier-vs-ESLint config wars often enough that “one tool that does both, and they don’t fight” stopped sounding like a marketing pitch and started sounding like sanity.

What Biome actually is, in two sentences

Biome is a Rust-based toolchain that bundles a linter and a formatter into one binary. It speaks JavaScript, TypeScript, JSX, JSON, and CSS, and as of Biome v2, it supports type-aware lint rules without needing the TypeScript service running in a separate process.

That last part is the real story. The thing that always made ESLint slow on a TypeScript project was spinning up the TypeScript parser and running type-aware analyses against tsc. Biome’s type-aware mode runs its own inference inside the same Rust process. Same checks, much less ceremony.

My install and config, before and after

Before, in the monorepo root, the dev dependencies were already getting absurd:

{
  "devDependencies": {
    "eslint": "^9.10.0",
    "@eslint/js": "^9.10.0",
    "typescript-eslint": "^8.6.0",
    "eslint-plugin-react": "^7.36.1",
    "eslint-plugin-react-hooks": "^4.6.2",
    "eslint-plugin-jsx-a11y": "^6.10.0",
    "eslint-plugin-import": "^2.30.0",
    "prettier": "^3.3.3",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-prettier": "^5.2.1"
  }
}

Ten packages. Several with their own peer-dependency drama on every minor bump. Add to that a real eslint.config.mjs:

import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import jsxA11y from "eslint-plugin-jsx-a11y";
import importPlugin from "eslint-plugin-import";
import prettier from "eslint-config-prettier";

export default tseslint.config(
  js.configs.recommended,
  ...tseslint.configs.recommendedTypeChecked,
  {
    plugins: {
      "react-hooks": reactHooks,
      "jsx-a11y": jsxA11y,
      import: importPlugin,
    },
    languageOptions: {
      parserOptions: {
        project: ["./tsconfig.json", "./packages/*/tsconfig.json"],
      },
    },
    rules: {
      "react-hooks/rules-of-hooks": "error",
      "react-hooks/exhaustive-deps": "warn",
      "import/order": ["warn", { "newlines-between": "always" }],
    },
  },
  prettier,
);

And a .prettierrc.json because of course there is one of those too. And a .eslintignore. And a .prettierignore.

After, the entire setup fits in one file:

{
  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
  "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
  "files": { "ignoreUnknown": true },
  "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "correctness": { "useExhaustiveDependencies": "warn" },
      "style": { "useImportType": "error" }
    }
  },
  "javascript": { "formatter": { "quoteStyle": "double", "trailingCommas": "all" } }
}

One file. One devDependency, @biomejs/biome. That’s it.

The migration itself was two commands:

npx @biomejs/biome migrate eslint --write
npx @biomejs/biome migrate prettier --write

The Biome CLI reads your old configs and ports what it can. The bits it can’t port it tells you about, and ninety percent of those, for me, were rules I’d added in a panic at 2 AM and never actually needed.

Where Biome was faster, and by how much

Numbers from my monorepo: three packages, around 38,000 lines of TypeScript, MacBook Pro M3, warm cache, three runs averaged.

Tool Lint only Format only Lint + format
ESLint 9 + Prettier 3 36.4 s 8.1 s 44.1 s
Biome 2 (no type-aware) 1.2 s 0.6 s 1.5 s
Biome 2 (type-aware on) 4.8 s 0.6 s 5.1 s

The non-type-aware lint went from thirty-six seconds to a second and a half. The type-aware run, the one I actually care about for CI, dropped from forty-four seconds to five.

In CI on GitHub-hosted runners, the wall-clock for the lint job dropped from one minute fourteen seconds to seven seconds. The whole pipeline shaved off about a minute, mostly because I’d been splitting lint and format into two jobs to avoid blocking PRs. With Biome I went back to one job and stopped thinking about it.

For tooling that touches every file on every commit, that’s the kind of speed-up that changes what you’ll do in a pre-commit hook. I went back and added a Biome check to a project where I’d given up on lint hooks two years ago because they were too slow to keep. Same way I felt about pnpm replacing npm when my CI cache finally stopped feeling like a punishment.

Where Biome still trails ESLint

I want to be fair. The honest gap as of June 2026:

  1. Plugin ecosystem. ESLint’s catalogue of community rules is enormous. Biome has been catching up rule-by-rule and hit feature parity on the rules I actually used. But if you depend on a niche plugin, some company-internal rule pack, some deep accessibility rules, check first. Biome’s useValidAriaProps and friends cover most of what I’d otherwise lose.
  2. Vue and Svelte. Biome’s first-class support is JS/TS/JSX/JSON/CSS. Vue SFC support is in beta. If you’re a Svelte shop, you’re not the target audience yet.
  3. Custom rules. Writing a custom ESLint rule is a fifteen-minute job. Biome’s plugin system works but is still maturing. I had one internal rule (a no-process.env.MY_FLAG check) and I left ESLint installed just to run that one rule. Took me five minutes of guilt before I admitted that was fine.
  4. Editor integration. The Biome VS Code extension works well, but if you’d been deep in the ESLint extension’s settings, you’ll spend an evening reconfiguring. The official Biome VS Code extension is what you want, not any third-party variant.

I read ESLint’s own blog reflecting on the rise of Rust-based competitors, and it’s a pretty grown-up response. They’re not trying to out-Rust Biome. They’re leaning into the plugin ecosystem they already have, which is the right call.

My current setup, end of June 2026

For a fresh Next.js or Vite project, this is what I run:

  • @biomejs/biome for lint and format.
  • ESLint kept only if I have a single custom rule that can’t be replaced. I run it as a second, slower CI step that’s allowed to be slow.
  • No eslint-config-prettier. No eslint-plugin-prettier. No arguing about which one should win when they disagree.
  • A lint-staged config that runs biome check --apply --staged on commit.

The package.json scripts I keep:

{
  "scripts": {
    "format": "biome format --write .",
    "lint": "biome lint .",
    "check": "biome check --write .",
    "ci:check": "biome ci ."
  }
}

The biome ci command is the one I wish I’d had years ago. It’s a single command that fails loudly on any formatting or lint issue without touching files. The --check-vs---write decision fatigue is gone. On a fresh repo I add a GitHub Actions step that runs pnpm ci:check after install, and that’s the entire lint pipeline.

For TypeScript projects specifically, turn on type-aware lint rules. The five-second penalty is worth the catches. I write more about the broader TypeScript story in TypeScript satisfies, when I stopped reaching for as.

If you want to see what a finished JS and TS toolchain stack looks like on a real product, that’s basically what I’m running on every client project this year, including the ones I cover on my work page.

One concrete thing to do this week

Pick the slowest CI lint job you own. Run:

npx @biomejs/biome init
npx @biomejs/biome migrate eslint --write
npx @biomejs/biome migrate prettier --write
npx @biomejs/biome check --write .

Look at the diff. If the changes are reasonable (mine were almost entirely import sorting), commit them on a branch and run your test suite. If the suite passes, run biome ci in CI in parallel with ESLint for a week. If nobody on the team complains, delete ESLint and Prettier from package.json and feel the disk space come back.

I should have done this six months ago. Don’t be me.