For fifteen years the JavaScript toolchain was written in JavaScript: the compiler, the bundler, the linter, the formatter, the test runner. That made sense. The people writing 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 every tool paid the JavaScript tax, a garbage collected, single threaded runtime doing work that's almost all 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, and this summer the compiler itself. It's Rust and Go, mostly Rust, and the speedups are big enough to turn a build you wait for into one you don't notice.
I moved a monorepo across to all of it in stages. Here's the full before and after, and the two places where the rewrite cost me something.
The before and after
The repo has six packages, a Next.js app and about 41,000 lines of TypeScript, with CI on a four core runner. Timings are for a cold run of the whole 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 with caching, most of the CI wall clock is now the runner starting up. Locally, what I notice most is that the pre-commit hook, which runs lint, format and typecheck on staged files, takes under a second. It used to take eight, which is long enough for people to learn to skip it.
What each replacement is
pnpm is the odd one out, because it's still written in TypeScript. It's on the list because it replaced npm in the same wave, and its speed comes from a different idea, a content addressed store with hard links, and not from a native rewrite.1
Biome is a Rust formatter and linter in one binary. It replaced ESLint, Prettier and the seven ESLint plugins the project had picked up over time. Its rules are mostly ports of the ESLint and typescript-eslint rules that matter. Its formatter is close enough to Prettier that switching produced a diff of 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 one pass.
TypeScript 7 is the Go port of the compiler.2 In short, it's the same type checker, native and parallel, and eight to twelve times faster.
Turbopack is Next.js's Rust bundler, the default for development since 15 and for production builds since 16. Rolldown is the Rust bundler Vite 8 is built on and tsdown uses for library builds.3
The monorepo has a small Python service, so on that side uv replaced pip, virtualenv and pip-tools. It's written in Rust by the people who wrote Ruff, and installs that took 40 seconds take 2.
Cost one: the plugin ecosystem
The first real cost is that a native tool can't run your JavaScript plugins, or can only run them slowly through a bridge.
For a lot of teams ESLint's value was never the core rules. It was the plugin that enforced the import order the team liked, the one that checked accessibility attributes, the one somebody wrote in 2021 to ban a specific internal pattern. Biome ships ports of the popular ones, plus a GritQL-based plugin system for simple structural rules, and that covered everything in my project. It won't cover a custom rule that walks type information, because a Rust linter that doesn't run the TypeScript checker has no type information.
I had one of those, a rule checking that every exported function in a particular directory had a JSDoc comment with a particular tag. It was ten lines of ESLint plugin. Now it's a twenty line script that runs the TypeScript compiler API on that one directory in the pre-commit hook, and takes a second. In general, the 95 percent of rules that are syntactic move to the native tool, and the 5 percent that need types become small standalone scripts.
Bundler plugins work the same way. Rolldown supports the Vite and Rollup plugin API, which is why the Vite 8 migration is mostly painless, but a JavaScript plugin that transforms every module runs in the JavaScript runtime and pulls bundle time back toward where it was. The Rolldown team's advice is to check whether what your plugin does is built in now, and often it is.
Cost two: you can no longer read the tool
I didn't expect to care about the second cost, and I 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.
Biome and Rolldown are written in Rust. Some of the team can read it, and most can't debug it. When Biome's formatter did something I disagreed with in a template literal, I could read Rust, file an issue or live with it, and I lived with it. The trade-off is real: the tool got faster and more opaque at the same time. I think it's the right trade, but I've watched a junior engineer bounce off a Rust stack trace where they'd happily have read a JavaScript one, and that costs the team, not just the build.
What softens it is that the native tools mostly have better error messages than the ones they replaced, because being opaque forces their authors to explain themselves in the output. Biome's lint messages are the best I've used. That still doesn't fully make up for it.
The order to do it in
If you're starting from the old stack on an existing project, this is the order that worked for me.
pnpm first. It's a package manager swap with no code changes, and it makes every install after it faster while you do the rest.
Biome second, replacing ESLint and Prettier in the same change. Run biome migrate to convert the configs, run the formatter once over the whole repo, commit that as a formatting only change, then turn on the lint rules. Treat the handful of rules with no Biome equivalent as things you no longer check, or move them to a script.
TypeScript 7 third, going through 6 and fixing the deprecations on the way.
The bundler last, because it has the most surface area and the most plugins. On Next.js that's Turbopack, which is the default, so it comes with the framework upgrade. Otherwise it's Vite 8 with Rolldown.
The migration diary
Every step had one or two things the migration guide didn't mention. Here they are, so you hit fewer of them.
pnpm. The store and the strict node_modules layout turned up four packages importing dependencies they hadn't declared, which worked under npm's hoisting and broke under pnpm's isolation. Each needed one line in a package.json. The fifth surprise was a Dockerfile that copied node_modules out of a build stage, which can't work when node_modules is a tree of symlinks into a store outside it. pnpm deploy produces a self contained directory for exactly this case, and the Dockerfile uses it now.
Biome. biome migrate eslint and biome migrate prettier converted both configs, and the result was close. After the first run the formatting diff was 340 lines out of 41,000, almost all of it JSX attribute wrapping and long template literals, where Biome and Prettier choose differently. I went with Biome's choices and committed the reformat by itself so it could be reviewed as "no logic changes". Of the ESLint rules we had on, 71 had a Biome equivalent and 9 didn't. Of those 9, 7 were rules I couldn't remember the reason for, and 2 became scripts.
TypeScript 7. That has its own post. The one thing worth repeating is to 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 turned SVG files into React components, and one ran a Markdown transform. SVGs are now imported as URLs and rendered with an img tag where that's enough, and a build step generates components from the SVG directory for the handful that need to be components. The Markdown transform became an MDX plugin, which Turbopack supports through the standard @next/mdx integration. Both changes made things better, in that those loaders were the only webpack configuration in the project and now there's none.
tsdown. Its config format is close enough to tsup's that migrating meant renaming a file and changing four keys. Declaration files are generated by the same underlying tool as before, so the .d.ts output came out identical.4
uv. Nothing broke. The requirements.txt became a pyproject.toml with a lockfile, and the Dockerfile lost three lines. It's the one migration on the list I'd call free.
What did not move
Node itself. The application runtime is still Node, because the services run on frameworks whose test matrices run on Node, and tooling speed has nothing to do with runtime speed.5
The test runner. Vitest is written in TypeScript and runs on Node, and it's fast enough that a native replacement would save a few seconds on a suite that takes 40. That's a different trade from saving 30 seconds on a linter. The native test runners that exist are tied to one runtime, and this suite runs on more than one, so 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 and left alone the libraries that run it, and I think that's the right line. A bundler is a compiler, and compilers want to be native. A UI library is something people read and debug all day, so it wants to be in the language people read.
Why I think this is permanent
There was a version of this story around 2016, when everyone was going to write tools in a compiled language. It didn't happen, because the tools weren't enough better to make up for the contribution cost. This time they are. A linter ten times faster changes what you can run on every keystroke, and a type checker ten times faster changes what the editor can do. Once a team has had a pre-commit hook that runs in under a second, nobody's going to vote to go back to eight seconds so the linter is easier to hack on.
The contribution cost is real, and it's moved to a smaller group of people who know Rust and Go. That's the trade the ecosystem made this year, mostly without talking about it, and I think it was the right one. The tools are infrastructure now, the way V8 is infrastructure, and most of us don't read V8 either.
Originally published at zeybek.dev.
-
Version 10 also turned off install scripts by default, which is the security change I'd make anyway. ↩
-
I've written about that migration separately. ↩
-
It exists because the Vite team got tired of using esbuild for development and Rollup for production and wanted one tool for both. ↩
-
I checked that with a diff, because declaration output is what a library's consumers see and I didn't want to ship them a surprise. ↩
-
Those are separate decisions, and I've written about the runtime one separately. ↩
Top comments (0)