I moved my linter and formatter out of ESLint and Prettier and into Oxlint and Oxfmt. The reason: waiting on pre-push checks had slowly started to wear on me.
This project uses Husky to run lint and type checks before every push. Since I started developing with AI, the loop from fixing code to pushing it has gotten much faster. And the faster that loop gets, the more the wait wedged into every push stands out.
If lint is going to run on every push, it had better finish in a blink. So I finally got around to Oxlint and Oxfmt, which I'd had my eye on for a while.
Oxlint and Oxfmt
Oxlint and Oxfmt are JavaScript and TypeScript tools developed by the Oxc project. Each one stands in for a familiar tool.
| Before | After | Purpose |
|---|---|---|
| ESLint | Oxlint | Static analysis |
| Prettier | Oxfmt | Code formatting |
Oxc is written in Rust and sells itself on speed. I'd heard it was fast, but I had no idea how much difference it would make on my own project.
The setting is a pnpm monorepo. Underapps/ sit a backend built with NestJS, a React frontend, and several smaller apps such as an admin panel, a help site, and internal tools. I moved the ESLint configuration of each of these apps over to Oxlint.
Moving the linter
Here are the results. All numbers were measured on the actual project, and the total is a simple sum of each app's time.
| Target | ESLint | Oxlint | Speedup |
|---|---|---|---|
| Backend | ~33s | ~4.6s | ~7x |
| Frontend | ~15s | ~0.4s | ~37x |
| Other apps | ~12s | ~0.5s | ~24x |
| Total | ~60s | ~5.5s | ~11x |
Note that the backend's Oxlint time doesn't include the naming-convention checks described below, which still run on ESLint.
The frontend saw the biggest win. What took about 15 seconds with ESLint finishes in 0.4 seconds with Oxlint. The first time I ran it, it ended so abruptly that I doubted it was checking anything at all. It was, of course, doing its job.
The backend got about 7 times faster too. For the purpose of cutting pre-push wait time, that alone more than pays for itself.
Furniture left at the old place
That said, I didn't banish ESLint entirely. When you move, furniture that won't fit in the new place tends to stay at the old one for a while.
The backend follows Clean Architecture, with its own constraints on dependencies and naming. On the dependency side, there are rules like: the Application layer doesn't import TypeORM directly, the Infrastructure layer doesn't throw HTTP exceptions, and Controllers don't reference the Infrastructure layer directly. Those are now checked by Oxlint.
Naming conventions, on the other hand, were enforced with ESLint'sno-restricted-syntax. UseCase names, for example, are held to rules like these.
| Names to avoid | Preferred names |
|---|---|
| Save | Create / Update |
| Edit | Update |
| Fetch | Get / List |
save or edit, use create or update instead. In this migration, only these naming-convention checks stayed in ESLint. As a result, the backend's lint runs on two tracks, Oxlint and ESLint.
Rather than hauling everything out at once, I judged it safer to move only what Oxlint could handle and keep the custom rules alive as they were. Oxlint also has a mechanism for reusing ESLint rules through JavaScript plugins, so sooner or later the naming checks may be folded into Oxlint too.
No spring cleaning on moving day
The migration also meant coming to terms with existing code. In the backend, two directories turned out to violate the architecture constraints I'd just introduced. One service in the Application layer used TypeORM directly, and one service in the Infrastructure layer threw HTTP exceptions.
Both are existing implementations, so I excluded them from the checks this time. The frontend also produced a handful of React-related warnings; I left those as warnings and filed them away for a separate task.
Switching lint tools and refactoring existing code are entirely different jobs. Start deep-cleaning the closets on moving day and you'll still be at it after dark. To keep the scope from ballooning, I prioritized the migration alone.
Moving the formatter too
Oxlint was faster than I'd imagined, so on a roll, I swapped out the formatter as well, putting Oxfmt in place of Prettier. I added a.oxfmtrc.json to each app and rewrote the format commands in package.json.
Then I compared run times on the backend's 2,218 files.
| Tool | Run time | Speedup |
|---|---|---|
| Prettier | 7.5s | - |
| Oxfmt | 82ms | ~91x |
7.5 seconds became 82 milliseconds. That one genuinely caught me off guard.
I'd never felt Prettier was painfully slow in daily use. Even so, watching some 2,200 files get done in under a tenth of a second makes the difference obvious. At that speed, you can run format checks casually even on projects with lots of files.
Of course, changing formatters can change the output, so you need to verify compatibility with your existing settings. In my case, I confirmed thatformat:check worked correctly after the migration.
The new floor plan
Here's where things ended up.
| Target | Linter | Formatter |
|---|---|---|
| Backend | Oxlint + ESLint (naming conventions only) | Oxfmt |
| Frontend | Oxlint | Oxfmt |
| Other apps | Oxlint | Oxfmt |
oxlint@^1.82.0 and oxfmt@^0.67.0. I also added commands to the root package.json so the whole monorepo can be checked in one go.
# Lint all apps
pnpm lint
# Format all apps
pnpm format
# Check formatting
pnpm format:check
A final end-to-end run showed both lint and the format check working fine.
Pairing it with Husky
As I mentioned at the start, this project's Huskypre-push hook runs the following.
pnpm lint
pnpm typecheck
That way, static analysis and type checking happen before every push. It's no substitute for CI, but stopping problematic code short of the push does cut down on pointless CI failures. And with lint now on Oxlint, the check itself got faster.
That said, I haven't yet put a number on how many GitHub Actions runs Husky has saved. What I've confirmed so far is only that the linter and formatter I run locally got faster.
Speed isn't everything in a tool
So that's the story of moving from ESLint and Prettier to Oxlint and Oxfmt. Measured, lint got about 7x faster on the backend, about 37x on the frontend, and about 24x on the other apps, and formatting got about 91x faster on the backend. Frankly, I didn't expect the gap to be this wide.
Of course, speed alone shouldn't decide your tools. ESLint has a rich plugin ecosystem and lets you build custom rules flexibly. I'm still leaving naming-convention checks to ESLint myself. Even so, the payoff from speeding up standard lint rules and formatting is big.
With AI in the loop, the cycle from generating code to fixing it has gotten much faster. That makes the seconds, or tens of seconds, spent on lint, type checks, and tests a real threat to the rhythm of development. Precisely because writing code takes less time now, the tools around it are worth a second look.