The JavaScript toolchain got rewritten and you should let it
For fifteen years the JavaScript toolchain was written in JavaScript. The compiler, the bundler, the linter, the formatter, the test runner. It made sense: the people who wrote the tools were JavaScript developers, and a tool written in the language it processes is easy for its users to contribute to. It also meant that every tool paid the JavaScript tax, a garbage collected single threaded runtime doing work that is almost entirely parsing and tree walking.
That era ended somewhere between esbuild in 2020 and TypeScript 7 in July this year. The bundler went native first. Then the formatter and linter. Then the package manager, twice. Then, this summer, the compiler itself. Rust and Go, mostly Rust, and the speedups are not incremental. They are the difference between a build you wait for and a build you do not notice.
I moved a monorepo across all of it in stages. This is the full before and after, and the two places where the rewrite cost me something.
The before and after
Six packages, a Next.js app, about 41,000 lines of TypeScript, CI on a four core runner. Timings are for a cold run of the full pipeline: install, lint, format check, typecheck, build.
| Step | Old tool | Old time | New tool | New time |
|---|---|---|---|---|
| Install | npm 10 | 48s | pnpm 10 | 11s |
| Lint | ESLint 9 + plugins | 34s | Biome 2 | 0.9s |
| Format check | Prettier 3 | 12s | Biome 2 | (same run) |
| Typecheck | tsc 5.9 | 38s | tsc 7.0 (Go) | 4.1s |
| App build | Next 15 (webpack) | 71s | Next 16 (Turbopack) | 19s |
| Library builds | tsup (esbuild) | 8s | tsdown (Rolldown) | 3s |
| Total | 211s | 38s |
The pipeline is five and a half times faster and the wall clock in CI, with caching, is now dominated by the runner starting up. Locally, the thing I feel most is that the pre-commit hook, which runs lint, format and typecheck on the staged files, is under a second. It used to be eight seconds, which is long enough that people learn to skip it.
What each replacement is
pnpm is the odd one out here, because it is still written in TypeScript. It is on the list because it replaced npm in the same wave and because the speed comes from a different idea, a content addressed store with hard links, rather than from a native rewrite. It also turned off install scripts by default in version 10, which is the security change I would make anyway.
Biome is a Rust formatter and linter in one binary. It replaced ESLint, Prettier, and the seven ESLint plugins the project had accumulated. The rules are mostly ports of the ESLint and typescript-eslint rules that matter, the formatter is Prettier compatible to the point where the diff when switching was a few hundred lines across the whole repo, and it runs in under a second because it parses each file once and does everything in that pass.
TypeScript 7 is the Go port of the compiler. I wrote about the migration separately. The short version is that it is the same type checker, native, parallel, and eight to twelve times faster.
Turbopack is Next.js's Rust bundler, default for development since 15 and for production builds since 16. Rolldown is the Rust bundler that Vite 8 is built on and that tsdown uses for library builds, and it exists because the Vite team got tired of having esbuild for development and Rollup for production and wanted one tool that did both.
On the Python side, since the monorepo has a small service in it, uv replaced pip and virtualenv and pip-tools. Written in Rust by the same people who wrote Ruff. Installs that took 40 seconds take 2.
Cost one: the plugin ecosystem
The first real cost is that a native tool cannot run your JavaScript plugins, or can only run them slowly through a bridge.
ESLint's value, for a lot of teams, was not the core rules. It was the plugin that enforced the import order the team liked, the plugin that checked accessibility attributes, the plugin someone wrote in 2021 that bans a specific internal pattern. Biome ships ports of the popular ones and a plugin system based on GritQL for simple structural rules, and that covered everything in my project. It will not cover a custom rule that walks the type information, because there is no type information in a Rust linter that does not run the TypeScript checker.
I had one of those. A rule that checked that every exported function in a specific directory had a JSDoc comment with a particular tag. It was ten lines of ESLint plugin. It is now a twenty line script that runs the TypeScript compiler API on that one directory, in the pre-commit hook, and takes a second. The general shape is that the 95 percent of rules that are syntactic move to the native tool and the 5 percent that need types become small standalone scripts.
The same applies to bundler plugins. Rolldown supports the Vite and Rollup plugin API, which is what makes the Vite 8 migration mostly painless, but a JavaScript plugin that transforms every module runs in the JavaScript runtime and drags the bundle time back toward where it was. The advice from the Rolldown team is to check whether the thing your plugin does is now built in, and it often is.
Cost two: you can no longer read the tool
The second cost is one I did not expect to care about and do. When ESLint gave a confusing result, I could open node_modules/eslint and read the rule. When Prettier formatted something oddly, I could put a breakpoint in it. Everyone on the team could, because it was the language we all wrote.
With Biome or Rolldown, the source is Rust. Some of the team can read it. Most cannot debug it. When Biome's formatter did something I disagreed with in a template literal, my options were to read Rust, file an issue, or accept it. I accepted it. The tradeoff is real: the tool is faster and the tool is more opaque, and I think the trade is right, but I have watched a junior engineer bounce off a Rust stack trace where they would have happily read a JavaScript one, and that is a cost to the team as well as to the build.
The mitigation is that the native tools have, on the whole, better error messages than the ones they replaced, because being opaque forces the authors to explain themselves in the output. Biome's lint messages are the best I have used. That does not fully make up for it.
The order to do it in
If you are starting from the old stack on an existing project, the order that worked for me:
pnpm first, because it is a package manager swap with no code changes and it makes every subsequent install faster while you do the rest.
Biome second, replacing both ESLint and Prettier in the same change. Run biome migrate to convert the configs, run the formatter once across the repo, commit that as a formatting only change, then turn on the lint rules. Accept the handful of rules that do not have a Biome equivalent as things you no longer check, or move them to a script.
TypeScript 7 third, via 6, fixing the deprecations in between.
The bundler last, because it is the one with the most surface area and the most plugins. Turbopack if you are on Next.js, and it is the default so this happens with the framework upgrade. Vite 8 with Rolldown otherwise.
The migration diary
Each step had one or two things that were not in the migration guide. Here they are, so yours has fewer.
pnpm. The store and the strict node_modules layout surfaced four packages that had been importing dependencies they did not declare, which worked under npm's hoisting and failed under pnpm's isolation. Each was a one line addition to a package.json. The fifth surprise was a Dockerfile that copied node_modules from a build stage, which does not work when node_modules is a tree of symlinks into a store outside it. pnpm deploy produces a self contained directory for exactly this and the Dockerfile now uses it.
Biome. biome migrate eslint and biome migrate prettier converted both configs and the result was close. The formatting diff after the first run was 340 lines across 41,000, almost all of it in JSX attribute wrapping and in long template literals, where Biome and Prettier make different choices. I accepted Biome's and committed the reformat on its own so the diff was reviewable as "no logic changes". Of the ESLint rules we had enabled, 71 had a Biome equivalent, 9 did not, and of those 9, 7 were rules I could not remember the reason for and 2 became scripts.
TypeScript 7. Covered in its own post. The one thing to repeat: check pnpm why typescript for tools that use the compiler as a library before you bump, and pin those to 6.
Turbopack. Two custom webpack loaders had to go: one that turned SVG files into React components and one that ran a Markdown transform. The SVG loader was replaced by importing SVGs as URLs and rendering them with an img tag where that was enough, and by a build step that generates components from the SVG directory for the handful that needed to be components. The Markdown transform became an MDX plugin, which Turbopack supports through the standard @next/mdx integration. Both changes were improvements, in the sense that the loaders had been the only two pieces of webpack configuration in the project and now there is none.
tsdown. The config format is close enough to tsup's that the migration was renaming a file and changing four keys. The declaration file output is generated by the same underlying tool as before, so the .d.ts files came out identical, which I checked with a diff because declaration output is the thing consumers of a library see and I did not want to ship a surprise.
uv. Nothing broke. The requirements.txt became a pyproject.toml with a lockfile, and the Dockerfile lost three lines. This is the one migration in the list I would describe as free.
What did not move
Node itself. The application runtime is still Node, because the services are on frameworks whose test matrices run on Node, and the tooling speed had nothing to do with the runtime speed. Those are separate decisions and I wrote about the runtime one separately.
The test runner. Vitest is written in TypeScript and runs on Node, and it is fast enough that a native replacement would save seconds on a suite that takes 40, which is not the same trade as saving 30 seconds on a linter. The native test runners that exist are tied to a specific runtime, and the suite runs on more than one. It stays.
The framework. Next.js is TypeScript, React is JavaScript, and neither is going anywhere. The rewrite wave hit the tools that process code, not the libraries that run it, and I think that is the right line. A bundler is a compiler and compilers want to be native. A UI library is the thing people read and debug all day, and it wants to be in the language people read.
Why I think this is permanent
There was a version of this story around 2016, where everyone was going to write tools in a compiled language, and it did not happen, because the tools were not better enough to overcome the contribution cost. This time they are. A ten times faster linter is not a nice to have, it changes what you can run on every keystroke. A ten times faster type checker changes what the editor can do. Once a team has had a sub-second pre-commit hook, nobody will vote to go back to eight seconds so that the linter is easier to hack on.
The contribution cost is real and it moved to a smaller group of people who know Rust and Go. That is the trade the ecosystem made this year, mostly without discussing it, and I think it made the right one. The tools are infrastructure now, the way the V8 engine is infrastructure, and most of us do not read V8 either.