{"id":364,"date":"2026-06-24T13:01:21","date_gmt":"2026-06-24T13:01:21","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/biome-vs-eslint-2026-the-linter-switch-i-kept-putting-off\/"},"modified":"2026-06-24T13:01:21","modified_gmt":"2026-06-24T13:01:21","slug":"biome-vs-eslint-2026-the-linter-switch-i-kept-putting-off","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/biome-vs-eslint-2026-the-linter-switch-i-kept-putting-off\/","title":{"rendered":"Biome vs ESLint in 2026: The Linter Switch I Kept Putting Off"},"content":{"rendered":"<p>I&rsquo;d been promising myself to &ldquo;look at Biome this weekend&rdquo; 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.<\/p>\n<p>Reader, it was not fine.<\/p>\n<p>This is the honest write-up I wanted to read before I did the switch. What broke, what didn&rsquo;t, the rough numbers from my own machine, and the parts where I&rsquo;m still keeping ESLint around for one specific reason. If you&rsquo;re a TypeScript developer who&rsquo;s been hearing &ldquo;just switch to Biome&rdquo; for months and waving it away, this one&rsquo;s for me a year ago.<\/p>\n<h2 id=\"why-i-held-out-on-eslint-for-so-long\">Why I held out on ESLint for so long<\/h2>\n<p>ESLint is the lingua franca. That&rsquo;s the honest reason. Every plugin I cared about, <code>eslint-plugin-react<\/code>, <code>eslint-plugin-import<\/code>, <code>eslint-plugin-jsx-a11y<\/code>, the TypeScript-ESLint stack, assumed ESLint&rsquo;s rule API. The big rewrites in ESLint 9 with <a href=\"https:\/\/eslint.org\/blog\/2024\/04\/eslint-v9.0.0-released\/\" rel=\"nofollow noopener\" target=\"_blank\">flat config<\/a> actually fixed my biggest complaint, which was the legacy <code>.eslintrc.json<\/code> cascade mess. Once I migrated to flat config, the urge to look elsewhere quietly dropped to zero.<\/p>\n<p>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&rsquo;d had enough.<\/p>\n<p>I&rsquo;d also been burned by Prettier-vs-ESLint config wars often enough that &ldquo;one tool that does both, and they don&rsquo;t fight&rdquo; stopped sounding like a marketing pitch and started sounding like sanity.<\/p>\n<h2 id=\"what-biome-actually-is-in-two-sentences\">What Biome actually is, in two sentences<\/h2>\n<p>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 <a href=\"https:\/\/biomejs.dev\/blog\/\" rel=\"nofollow noopener\" target=\"_blank\">Biome v2<\/a>, it supports type-aware lint rules without needing the TypeScript service running in a separate process.<\/p>\n<p>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 <code>tsc<\/code>. Biome&rsquo;s type-aware mode runs its own inference inside the same Rust process. Same checks, much less ceremony.<\/p>\n<h2 id=\"my-install-and-config-before-and-after\">My install and config, before and after<\/h2>\n<p>Before, in the monorepo root, the dev dependencies were already getting absurd:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;devDependencies&quot;: {\n    &quot;eslint&quot;: &quot;^9.10.0&quot;,\n    &quot;@eslint\/js&quot;: &quot;^9.10.0&quot;,\n    &quot;typescript-eslint&quot;: &quot;^8.6.0&quot;,\n    &quot;eslint-plugin-react&quot;: &quot;^7.36.1&quot;,\n    &quot;eslint-plugin-react-hooks&quot;: &quot;^4.6.2&quot;,\n    &quot;eslint-plugin-jsx-a11y&quot;: &quot;^6.10.0&quot;,\n    &quot;eslint-plugin-import&quot;: &quot;^2.30.0&quot;,\n    &quot;prettier&quot;: &quot;^3.3.3&quot;,\n    &quot;eslint-config-prettier&quot;: &quot;^9.1.0&quot;,\n    &quot;eslint-plugin-prettier&quot;: &quot;^5.2.1&quot;\n  }\n}\n<\/code><\/pre>\n<p>Ten packages. Several with their own peer-dependency drama on every minor bump. Add to that a real <code>eslint.config.mjs<\/code>:<\/p>\n<pre><code class=\"language-js\">import js from &quot;@eslint\/js&quot;;\nimport tseslint from &quot;typescript-eslint&quot;;\nimport reactHooks from &quot;eslint-plugin-react-hooks&quot;;\nimport jsxA11y from &quot;eslint-plugin-jsx-a11y&quot;;\nimport importPlugin from &quot;eslint-plugin-import&quot;;\nimport prettier from &quot;eslint-config-prettier&quot;;\n\nexport default tseslint.config(\n  js.configs.recommended,\n  ...tseslint.configs.recommendedTypeChecked,\n  {\n    plugins: {\n      &quot;react-hooks&quot;: reactHooks,\n      &quot;jsx-a11y&quot;: jsxA11y,\n      import: importPlugin,\n    },\n    languageOptions: {\n      parserOptions: {\n        project: [&quot;.\/tsconfig.json&quot;, &quot;.\/packages\/*\/tsconfig.json&quot;],\n      },\n    },\n    rules: {\n      &quot;react-hooks\/rules-of-hooks&quot;: &quot;error&quot;,\n      &quot;react-hooks\/exhaustive-deps&quot;: &quot;warn&quot;,\n      &quot;import\/order&quot;: [&quot;warn&quot;, { &quot;newlines-between&quot;: &quot;always&quot; }],\n    },\n  },\n  prettier,\n);\n<\/code><\/pre>\n<p>And a <code>.prettierrc.json<\/code> because of course there is one of those too. And a <code>.eslintignore<\/code>. And a <code>.prettierignore<\/code>.<\/p>\n<p>After, the entire setup fits in one file:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https:\/\/biomejs.dev\/schemas\/2.0.0\/schema.json&quot;,\n  &quot;vcs&quot;: { &quot;enabled&quot;: true, &quot;clientKind&quot;: &quot;git&quot;, &quot;useIgnoreFile&quot;: true },\n  &quot;files&quot;: { &quot;ignoreUnknown&quot;: true },\n  &quot;formatter&quot;: { &quot;enabled&quot;: true, &quot;indentStyle&quot;: &quot;space&quot;, &quot;indentWidth&quot;: 2 },\n  &quot;linter&quot;: {\n    &quot;enabled&quot;: true,\n    &quot;rules&quot;: {\n      &quot;recommended&quot;: true,\n      &quot;correctness&quot;: { &quot;useExhaustiveDependencies&quot;: &quot;warn&quot; },\n      &quot;style&quot;: { &quot;useImportType&quot;: &quot;error&quot; }\n    }\n  },\n  &quot;javascript&quot;: { &quot;formatter&quot;: { &quot;quoteStyle&quot;: &quot;double&quot;, &quot;trailingCommas&quot;: &quot;all&quot; } }\n}\n<\/code><\/pre>\n<p>One file. One devDependency, <code>@biomejs\/biome<\/code>. That&rsquo;s it.<\/p>\n<p>The migration itself was two commands:<\/p>\n<pre><code class=\"language-bash\">npx @biomejs\/biome migrate eslint --write\nnpx @biomejs\/biome migrate prettier --write\n<\/code><\/pre>\n<p>The Biome CLI reads your old configs and ports what it can. The bits it can&rsquo;t port it tells you about, and ninety percent of those, for me, were rules I&rsquo;d added in a panic at 2 AM and never actually needed.<\/p>\n<h2 id=\"where-biome-was-faster-and-by-how-much\">Where Biome was faster, and by how much<\/h2>\n<p>Numbers from my monorepo: three packages, around 38,000 lines of TypeScript, MacBook Pro M3, warm cache, three runs averaged.<\/p>\n<table>\n<thead>\n<tr>\n<th>Tool<\/th>\n<th>Lint only<\/th>\n<th>Format only<\/th>\n<th>Lint + format<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>ESLint 9 + Prettier 3<\/td>\n<td>36.4 s<\/td>\n<td>8.1 s<\/td>\n<td>44.1 s<\/td>\n<\/tr>\n<tr>\n<td>Biome 2 (no type-aware)<\/td>\n<td>1.2 s<\/td>\n<td>0.6 s<\/td>\n<td>1.5 s<\/td>\n<\/tr>\n<tr>\n<td>Biome 2 (type-aware on)<\/td>\n<td>4.8 s<\/td>\n<td>0.6 s<\/td>\n<td>5.1 s<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<p>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.<\/p>\n<p>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&rsquo;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.<\/p>\n<p>For tooling that touches every file on every commit, that&rsquo;s the kind of speed-up that changes what you&rsquo;ll do in a pre-commit hook. I went back and added a Biome check to a project where I&rsquo;d given up on lint hooks two years ago because they were too slow to keep. Same way I felt about <a href=\"https:\/\/abrarqasim.com\/blog\/pnpm-vs-npm-the-day-node-modules-stopped-eating-my-disk\" rel=\"noopener\">pnpm replacing npm<\/a> when my CI cache finally stopped feeling like a punishment.<\/p>\n<h2 id=\"where-biome-still-trails-eslint\">Where Biome still trails ESLint<\/h2>\n<p>I want to be fair. The honest gap as of June 2026:<\/p>\n<ol>\n<li><em>Plugin ecosystem.<\/em> ESLint&rsquo;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&rsquo;s <code>useValidAriaProps<\/code> and friends cover most of what I&rsquo;d otherwise lose.<\/li>\n<li><em>Vue and Svelte.<\/em> Biome&rsquo;s first-class support is JS\/TS\/JSX\/JSON\/CSS. Vue SFC support is in beta. If you&rsquo;re a Svelte shop, you&rsquo;re not the target audience yet.<\/li>\n<li><em>Custom rules.<\/em> Writing a custom ESLint rule is a fifteen-minute job. Biome&rsquo;s plugin system works but is still maturing. I had one internal rule (a no-<code>process.env.MY_FLAG<\/code> check) and I left ESLint installed just to run that one rule. Took me five minutes of guilt before I admitted that was fine.<\/li>\n<li><em>Editor integration.<\/em> The Biome VS Code extension works well, but if you&rsquo;d been deep in the ESLint extension&rsquo;s settings, you&rsquo;ll spend an evening reconfiguring. The official <a href=\"https:\/\/biomejs.dev\/reference\/vscode\/\" rel=\"nofollow noopener\" target=\"_blank\">Biome VS Code extension<\/a> is what you want, not any third-party variant.<\/li>\n<\/ol>\n<p>I read <a href=\"https:\/\/eslint.org\/blog\/\" rel=\"nofollow noopener\" target=\"_blank\">ESLint&rsquo;s own blog<\/a> reflecting on the rise of Rust-based competitors, and it&rsquo;s a pretty grown-up response. They&rsquo;re not trying to out-Rust Biome. They&rsquo;re leaning into the plugin ecosystem they already have, which is the right call.<\/p>\n<h2 id=\"my-current-setup-end-of-june-2026\">My current setup, end of June 2026<\/h2>\n<p>For a fresh Next.js or Vite project, this is what I run:<\/p>\n<ul>\n<li><code>@biomejs\/biome<\/code> for lint and format.<\/li>\n<li>ESLint kept only if I have a single custom rule that can&rsquo;t be replaced. I run it as a second, slower CI step that&rsquo;s allowed to be slow.<\/li>\n<li>No <code>eslint-config-prettier<\/code>. No <code>eslint-plugin-prettier<\/code>. No arguing about which one should win when they disagree.<\/li>\n<li>A <code>lint-staged<\/code> config that runs <code>biome check --apply --staged<\/code> on commit.<\/li>\n<\/ul>\n<p>The <code>package.json<\/code> scripts I keep:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;scripts&quot;: {\n    &quot;format&quot;: &quot;biome format --write .&quot;,\n    &quot;lint&quot;: &quot;biome lint .&quot;,\n    &quot;check&quot;: &quot;biome check --write .&quot;,\n    &quot;ci:check&quot;: &quot;biome ci .&quot;\n  }\n}\n<\/code><\/pre>\n<p>The <code>biome ci<\/code> command is the one I wish I&rsquo;d had years ago. It&rsquo;s a single command that fails loudly on any formatting or lint issue without touching files. The <code>--check<\/code>-vs-<code>--write<\/code> decision fatigue is gone. On a fresh repo I add a GitHub Actions step that runs <code>pnpm ci:check<\/code> after install, and that&rsquo;s the entire lint pipeline.<\/p>\n<p>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 <a href=\"https:\/\/abrarqasim.com\/blog\/typescript-satisfies-when-i-stopped-reaching-for-as\" rel=\"noopener\">TypeScript satisfies, when I stopped reaching for as<\/a>.<\/p>\n<p>If you want to see what a finished JS and TS toolchain stack looks like on a real product, that&rsquo;s basically what I&rsquo;m running on every client project this year, including the ones I cover on <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my work page<\/a>.<\/p>\n<h2 id=\"one-concrete-thing-to-do-this-week\">One concrete thing to do this week<\/h2>\n<p>Pick the slowest CI lint job you own. Run:<\/p>\n<pre><code class=\"language-bash\">npx @biomejs\/biome init\nnpx @biomejs\/biome migrate eslint --write\nnpx @biomejs\/biome migrate prettier --write\nnpx @biomejs\/biome check --write .\n<\/code><\/pre>\n<p>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 <code>biome ci<\/code> in CI in parallel with ESLint for a week. If nobody on the team complains, delete ESLint and Prettier from <code>package.json<\/code> and feel the disk space come back.<\/p>\n<p>I should have done this six months ago. Don&rsquo;t be me.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>After ten weekends of telling myself it could wait, I swapped ESLint plus Prettier for Biome on a TypeScript monorepo. Real numbers, what broke, what stayed.<\/p>\n","protected":false},"author":2,"featured_media":363,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"After ten weekends of telling myself it could wait, I swapped ESLint plus Prettier for Biome on a TypeScript monorepo. Real numbers, what broke, what stayed.","rank_math_focus_keyword":"biome vs eslint","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[165,35],"tags":[296,297,44,411,410,63],"class_list":["post-364","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-javascript","category-web-development","tag-biome","tag-eslint","tag-javascript","tag-linter","tag-prettier","tag-typescript"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/364","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=364"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/364\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/363"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=364"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=364"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=364"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}