Linting a JavaScript project used to be one decision. Now there are three viable tools, two of them written in Rust, and ESLint itself has changed its configuration format in a way that breaks older setups outright. The choice matters less than people argue about, but the wrong combination costs you either twenty seconds on every save or a rule you needed and did not have. What follows is what each tool is genuinely good at, the ESLint migration you cannot avoid, and the CI flags that stop lint output turning into background noise.
ESLint 9 made eslint.config.js the default and dropped .eslintrc support without a flag. The new format is a plain array of config objects, evaluated in order, with later objects overriding earlier ones. Plugins are imported as real modules instead of being resolved from a string, which kills the old class of "cannot find plugin from a nested config" bugs, and .eslintignore is replaced by an ignores key.
import js from "@eslint/js";
import ts from "typescript-eslint";
export default [
{ ignores: ["dist/**", "coverage/**"] },
js.configs.recommended,
...ts.configs.recommended,
{ files: ["**/*.test.ts"], rules: { "no-console": "off" } },
];
The one genuine loss is cascading: flat config no longer picks up a config file per directory, so monorepo packages that each had their own overrides need those merged into one root array with files globs.
Rules like no-floating-promises and no-misused-promises catch the bugs nobody catches in review, and they cannot work from the syntax tree alone. Turning on projectService makes ESLint build a full TypeScript program, which typically turns a two-second lint into a run measured in tens of seconds on a mid-sized app, because you are effectively type-checking twice. Two ways to live with it: apply the type-aware config only to src/**/*.ts and leave config and script files on the syntactic set, or keep type-aware rules out of the editor and run them as a separate CI job.
Biome is a Rust toolchain that lints and formats in one binary, with no plugin resolution and no Node startup, and it is fast enough that the lint step stops being something you notice. It has a Prettier-compatible formatter, so adopting it can delete Prettier, ESLint and eslint-config-prettier from a project in one commit. The honest limitation is the rule set: Biome ships its own ported rules, and if your team relies on a niche plugin — a testing-library ruleset, an internal custom rule, a framework-specific plugin — there may be no equivalent. Check your current rule list against Biome's before promising a migration.
Oxlint is also Rust and also very fast, and it deliberately implements the high-value correctness rules rather than all of them. The pattern that works is Oxlint on every save and on every push as the fast gate, ESLint with the type-aware rules on the slower pre-merge job. You get near-instant feedback on the mistakes that are cheap to detect and keep the deep analysis where a few extra seconds do not hurt.
ESLint's stylistic core rules — quotes, semi, indent, comma-dangle — are deprecated. Formatting is a solved problem, and running it through a linter means every whitespace disagreement becomes an error with a fixer that can fight your editor. Run Prettier or Biome's formatter, then apply eslint-config-prettier last in the array so it switches off anything that would conflict. What is left in ESLint is what it is good at: logic errors, unused values, unsafe patterns.
eslint . --cache --cache-location node_modules/.cache/eslint — reuses results for unchanged files; cache the directory between CI runs.--max-warnings 0 in CI. Warnings that never fail a build become permanent, and a codebase with 400 of them has no signal left.--no-verify.That plugin list is also the argument against a full switch to a Rust linter today. ESLint is slower and its config has churned, but the ecosystem around it is still the only one that covers React, accessibility, imports and test frameworks at once.