Biome

23. August, 2025 12 min read Develop

One binary instead of six packages

Open the devDependencies of any frontend project older than a year and count how many of them exist purely to make the linter and the formatter stop arguing. On this site the answer is six: eslint, prettier, eslint-config-prettier, eslint-plugin-prettier, stylelint and stylelint-prettier, plus a handful of plugins underneath. None of that is doing work I asked for. It is doing the work of reconciling two tools that both have opinions about where a line break goes.

Biome is the attempt to remove that entirely. One binary, written in Rust, that formats, lints and sorts imports, configured by one file. Version 2 landed in June 2025 and is the release where I stopped filing it under “interesting, ask again next year”.

Where it came from

Biome is what is left of Rome, the toolchain Sebastian McKenzie started after Babel and Yarn. Rome was going to replace everything in the JavaScript build pipeline at once. It was written in TypeScript, then rewritten in Rust for speed, and backed by a company.

The company failed. When the layoffs came, the core contributors found they had been locked out of the infrastructure they had been maintaining: no npm registry access, no Discord, no website. In August 2023 they forked it and shipped under a new name, made by fusing “bis” onto “Rome”. Second Rome.

I have a mild fondness for that origin story. The project people remember is the venture-funded one that stopped; the one that is still shipping releases is the fork the maintainers made when they had no infrastructure and no salary.

The tax it is removing

The ESLint and Prettier pairing works, and I have run it for years. The friction is not in any one piece:

  • Two configuration files, and a third package whose only job is switching off the ESLint rules that fight Prettier.
  • Two tools with overlapping jurisdiction, which is why that third package needs to exist.
  • JavaScript performance, on a workload that is entirely parsing and walking trees.
  • Plugin arithmetic. A typical React setup pulls in @typescript-eslint/parser, @typescript-eslint/eslint-plugin, eslint-plugin-react, eslint-plugin-import and eslint-plugin-jsx-a11y, each with its own peer dependency range, all of which have to agree before anything runs.

That last one is the one that actually costs time. Nobody minds a slow linter as much as they mind a major ESLint release forcing a coordinated bump across five plugins that update on five different schedules. It is the same complaint I ended up with when writing about Conventional Commits: the tooling around the convention kept demanding more attention than the convention itself.

Speed

Biome’s own benchmark is formatting 171,127 lines across 2,104 files on an Intel Core i7 1270P, where it comes out roughly 35 times faster than Prettier. Take the multiplier with the usual pinch of salt, since it is the vendor’s own number on the vendor’s own hardware, but the order of magnitude is not in dispute.

Where you feel it is not the full run. It is the pre-commit hook and the editor. A formatter that takes 800ms to start up before it does anything is a formatter you eventually configure to run less often, and then you stop trusting the repo to be formatted. Biome keeps a daemon alive for editor requests, so there is no per-invocation startup at all.

Getting it in

Install it pinned, because formatter output does drift across versions and an unpinned formatter turns one person’s save into a hundred-line diff:

npm install --save-dev --save-exact @biomejs/biome

Then generate a config:

npx @biomejs/biome init

Three commands cover daily use:

# Format files
npx biome format --write .

# Lint files
npx biome lint --write .

# Format, lint, and apply assist actions in one pass
npx biome check --write .

check is the one that ends up in your scripts. It runs the formatter, applies the safe lint fixes, and runs the assist actions, which in v2 is where import sorting now lives.

The config file

Everything goes in biome.json, or biome.jsonc if you want comments. A version tuned for a React and TypeScript project:

{
  "$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
  "formatter": {
    "enabled": true,
    "indentStyle": "space",
    "indentWidth": 2,
    "lineWidth": 100
  },
  "linter": {
    "enabled": true,
    "rules": {
      "recommended": true,
      "complexity": {
        "noUselessFragments": "warn",
        "noForEach": "off"
      },
      "style": {
        "noNonNullAssertion": "warn"
      },
      "suspicious": {
        "noExplicitAny": "warn"
      }
    }
  },
  "assist": {
    "actions": {
      "source": {
        "organizeImports": "on"
      }
    }
  },
  "javascript": {
    "formatter": {
      "quoteStyle": "single",
      "semicolons": "always",
      "trailingCommas": "all"
    }
  },
  "vcs": {
    "enabled": true,
    "clientKind": "git",
    "useIgnoreFile": true,
    "defaultBranch": "main"
  },
  "files": {
    "includes": ["src/**", "tests/**"]
  }
}

No parser configuration anywhere. Biome works out that a .tsx file is TypeScript with JSX without being told, which after years of parserOptions blocks feels like something is missing.

Two things moved in v2 and will bite you if you are upgrading from v1 rather than starting fresh. files.include and files.ignore were replaced by a single files.includes list that takes negated globs with !. And organizeImports is no longer a top-level key; it is an assist action, as above. Running npx @biomejs/biome migrate --write after a version bump rewrites the config for you, which is a nice habit to have even when nothing has changed.

The formatter

Biome formats JavaScript, TypeScript, JSX, TSX, JSON, JSONC, CSS and GraphQL. It passes 97% of Prettier’s test suite, which is how it won the Prettier Challenge back in 2023.

The 3% is worth knowing before you run it on a repository full of other people’s code:

  • The default indent style is tabs, where Prettier defaults to spaces. Set "indentStyle": "space" if you would rather not explain the diff.
  • Object property unquoting follows ES2015 identifier rules rather than Prettier’s ES5 ones, so a few more keys lose their quotes.
  • The parser is stricter. Things Prettier’s Babel parser waves through, like duplicate modifiers, are errors here.

Expect one enormous reformatting commit and then nothing. Put its hash in .git-blame-ignore-revs and move on.

Opting out of formatting for a block uses a comment:

// biome-ignore format: complex template literal alignment
const query = `
  SELECT * FROM users
  WHERE  id = ${userId}
  AND    active = true
`;

The linter

Rules are grouped into eight domains: accessibility, complexity, correctness, nursery, performance, security, style and suspicious. Nursery is opt-in and holds whatever is still settling.

Most rules are ports of existing ones from ESLint core, typescript-eslint, eslint-plugin-react, eslint-plugin-jsx-a11y and eslint-plugin-unicorn. The naming convention changed, though: Biome uses camelCase where ESLint uses kebab-case, and often renames on the way, so no-unused-vars arrives as noUnusedVariables. Any suppression comment you have memorised is wrong here, and so is any rule name in your team’s coding guidelines.

Type-aware rules without the TypeScript compiler

This is the headline of v2 and the reason it is worth a second look. typescript-eslint’s type-aware rules are the slowest part of most lint runs, because they need a full TypeScript program in memory. Biome v2 does its own type inference across files instead, so rules like noFloatingPromises work with no tsconfig.json wiring at all:

// Biome catches this without the TypeScript compiler
async function fetchData() {
  return await fetch('/api/data');
}

// Error: noFloatingPromises - This promise must be awaited or handled
fetchData();

The coverage is partial. Biome puts it at roughly three quarters of what typescript-eslint catches, which is the honest way to describe it and also the thing to check before you delete anything. If your team relies on a specific type-aware rule, look it up rather than assuming.

Safe and unsafe fixes

Every fix is classified:

# Apply safe fixes only (semantics-preserving)
npx biome lint --write .

# Apply safe AND unsafe fixes (may change behavior)
npx biome lint --write --unsafe .

A safe fix cannot change what your program does: dropping an unused import, collapsing a redundant expression. An unsafe fix might, and rewriting a for loop into Array.map is the example that makes the distinction obvious. Having the two separated by a flag rather than by rule documentation is the difference between running the fixer in CI and not.

Imports

Import organising was rebuilt in v2. It merges duplicate imports from the same module, sorts the specifiers inside each one, understands import attributes, and handles exports as well:

// Before
import { useState } from 'react';
import { z } from 'zod';
import { useEffect } from 'react';
import type { FC } from 'react';
import { db } from '@/lib/db';

// After (biome check --write)
import type { FC } from 'react';
import { useEffect, useState } from 'react';

import { db } from '@/lib/db';

import { z } from 'zod';

It also respects blank lines you put in deliberately, treating them as group boundaries rather than noise to be tidied away. That single behaviour removes the reason most people reach for eslint-plugin-import or a Prettier sorting plugin in the first place.

Migrating

Biome reads your existing configuration and converts it:

# Migrate ESLint config
npx @biomejs/biome migrate eslint --write

# Migrate Prettier config
npx @biomejs/biome migrate prettier --write

The ESLint side handles both the legacy .eslintrc format and flat config, resolves shared configs and plugins, and maps rules onto their Biome equivalents where they exist. Rules with no equivalent are dropped, so read the output rather than trusting it.

If the result is a wall of violations, you do not have to fix them before adopting the tool. Write suppression comments for everything currently failing and work through them later:

npx biome lint --write --suppress --reason="suppressed during Biome migration"

Then the uninstall, which is the satisfying part:

npm uninstall eslint prettier eslint-config-prettier eslint-plugin-react \
  @typescript-eslint/parser @typescript-eslint/eslint-plugin \
  eslint-plugin-import eslint-plugin-jsx-a11y

Eight packages, and every one of them was a version constraint you had to keep satisfied.

Editors and CI

First-party extensions exist for VS Code, IntelliJ and Zed, with community ones for Vim, Neovim and Sublime Text. In VS Code the settings are:

{
  "editor.defaultFormatter": "biomejs.biome",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "quickfix.biome": "explicit",
    "source.organizeImports.biome": "explicit"
  }
}

For CI there is a purpose-built command that checks everything and fixes nothing:

npx biome ci .

It reports formatting, lint and assist violations and exits non-zero. On a pull request you rarely want the whole repository, and Biome knows about your VCS:

# Only check files changed since the default branch
npx biome check --changed

# Only check staged files, for a pre-commit hook
npx biome check --staged

Which makes for a short workflow file:

name: Code Quality
on: [pull_request]

jobs:
  biome:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx biome ci .

What it will not touch

Language Format Lint
JavaScript / TypeScript Yes Yes
JSX / TSX Yes Yes
JSON / JSONC Yes Yes
CSS Yes Yes
GraphQL Yes Nursery rules
HTML Experimental No

The absences matter more than the table suggests. SCSS, Sass and Less are not supported, only plain CSS. Vue, Svelte and Astro single-file components are on the roadmap and not here yet. If your project contains either of those, Biome is an addition to your toolchain rather than a replacement for it, and the whole argument for adopting it gets weaker.

What v2 brought

Beyond type-aware linting, the June release added a plugin system built on GritQL for writing your own pattern-matching rules, though the distribution story for sharing them is still being worked out. Assist actions arrived as a category separate from lint diagnostics, covering things like useSortedKeys and useSortedAttributes that are preferences rather than problems. Suppressions gained a range form with // biome-ignore-start and // biome-ignore-end, which spares you a comment per line when you need to exempt a block. Monorepos got nested configuration files that inherit from the root. And the HTML formatter appeared, experimental and off by default.

Would I switch this site?

No, and the reason is the third row from the bottom of that table.

This blog is Gatsby with Bootstrap and a stack of hand-written SCSS, linted by Stylelint with a handful of plugins on top of it. Biome does not do SCSS. So switching would mean replacing ESLint and Prettier with Biome, keeping Stylelint anyway, and ending up with two tools and two config files, which is precisely the thing I would be switching to avoid. The dependency count barely moves.

For a project that is TypeScript and plain CSS, I would use it without hesitating, and I would do it on day one rather than migrating later. That is not really a caveat about Biome, it is a caveat about my own repositories, most of which predate the assumption that CSS is something you write directly.

The thing I will actually do is smaller. The next time an ESLint major forces me to bump five plugins in lockstep, I am going to open that language support table again and check whether the SCSS row has moved

‘Till next time!