Introducing oj: a Rust build tool that speaks Vite

Draft. Numbers below come from oj’s own benchmark suite.

Most of the time you don’t think about your dev server. You run npm run dev, you wait, you get a URL. The waiting is the part I kept thinking about.

For the last few months I’ve been building oj: a dev server and bundler written in Rust that you can point at an existing Vite + React project and it just runs: the same vite.config.ts, the same plugins, no rewrite. It’s now good enough that I point it at real production apps, and I want to explain what it is, why it exists, and show you the numbers.

The two things I wanted

There are already fast bundlers. What I wanted was a specific combination that didn’t exist yet:

  1. Speak Vite, not webpack. The apps I care about are Vite apps. They have a vite.config.ts, they use @vitejs/plugin-react, vite-plugin-svgr, MDX, and a pile of project-specific plugins. A tool is only a drop-in if it runs those, unchanged. So oj reads your real Vite config and runs your real Vite plugins through a compatibility bridge. It also reimplements React Fast Refresh and TanStack Start natively, because those are the frameworks I build on.

  2. No Node, no node_modules, one binary. oj is a single Rust binary. It does not need a JavaScript runtime to drive it and it does not install a toolchain into your project. That matters most where I run it: ephemeral sandboxes, where a slimmer image and a faster cold start compound across thousands of previews.

Under the hood it’s built on rolldown and oxc (the same Rust foundations the Vite team is moving toward), with an on-demand, unbundled dev server in front, and lazy compilation so opening one route doesn’t pay for the whole app.

The numbers

Here is oj dev --bundle against Vite’s default dev on the same 10,000-component project: cold start, warm start, and a full page reload. Watch it fill:

Cold start 4.3× faster oj 1315ms · Vite 5693ms

A few things worth saying about those numbers. The start figures are the headline, and they hold as the app grows: on the 10,000-component tree oj cold-starts in about a quarter of Vite’s time, and the reload loop you pay dozens of times an hour is where that lead compounds. oj’s default oj dev is an on-demand, unbundled server; the chart shows oj dev --bundle, which pre-bundles the client for the fastest loop.

Two honest caveats. A single hot edit (HMR) is now a wash: both oj and a well-tuned Vite land around 60ms, and I’m not going to pretend otherwise. And a well-tuned Vite is faster than a default one; I’m not interested in beating a strawman, so these runs are Vite configured sensibly, on the same machine, same app. The suite is bench/run.mjs in the repo, so you can reproduce it: node bench/run.mjs 10000.

The number that doesn’t narrow is memory. On that same app oj holds around 115MB where Vite sits above 1.5GB, a 13x gap that widens with app size. That is the difference that matters where I run oj most: thousands of ephemeral preview sandboxes, where every megabyte and every second of cold start is multiplied.

Running a real app

A benchmark on a toy app proves nothing. The bar I set for myself was: take a real, popular open-source Vite app that I did not write, don’t touch its config, and run it.

That is a genuinely hard bar, because real apps lean on the whole surface of Vite: regex resolve.alias for monorepo packages, source files outside the app root, TypeScript enums, import.meta.env, plugin virtual modules. Every one of those is a place a “Vite-compatible” tool can quietly fall short, and getting there is most of what the last stretch of work has been.

I picked Excalidraw, one of the most-starred open-source React apps on GitHub, and pointed oj at its excalidraw-app without changing a line of config. It runs. Getting there was exactly the list above: its monorepo packages wire through regex resolve.alias, its stylesheets use the .module.scss convention (which oj’s Sass engine now resolves the way dart-sass does), it imports TypeScript source from outside the app root, and it uses import.meta.env inside JSX. Every one of those was a place oj used to fall short, and each is now a fixed bug with a test.

The loop on that app, same machine, from oj dev (or vite) to the first canvas painting:

  cold start warm start memory
oj ~0.8s ~0.8s 288 MB
Vite ~2.3s ~1.1s 2.4 GB

oj boots it in under a second whether the cache is cold or warm, on about an eighth of the memory.

One honest caveat, and oj prints it on boot: it skips vite-plugin-checker, the plugin that runs tsc in a background worker and overlays type errors in the browser. oj cannot host that one (it wants a full Vite dev server that oj does not provide), so it logs skipping unsupported plugin "vite-plugin-checker" and carries on. The app it serves is the same either way; you just do not get the in-browser type overlay, and Vite’s numbers above include the worker that oj never starts.

The point of oj was never “another bundler.” It was: keep the ecosystem you already have (your config, your plugins, your framework) and make the loop underneath it disappear.

How it got here

oj started as a project to fix my own problems. I was working on another repository and watching agents run vite build over and over, each build process carrying gigabytes of memory, and I got tired enough to try building the thing I wished I had. That’s the whole origin: frustration, and free time.

At some point it started showing up in my day job at Lovable, quietly, behind a flag. People got excited. They started using it, filing issues, and sending patches. It’s still an experimental research project (the README still says use at your own risk, and it means it), but it’s no longer just mine.

The app it runs there is not a toy: a production TanStack Start build with a client graph around 18,000 modules and a vite.config that loads more than fifty plugins. That turned out to be the best stress test oj ever had. Getting it to boot that app in a couple of seconds instead of tens, on a fraction of the memory, didn’t come from one clever trick; it came from a stack of small, individually-measured changes: persistent caches for the codegen, the client bundle, and the SSR loader; a single plugin host instead of two; loader hooks moved in-thread. Each is a modest win on its own, and they compound. All of it lives in the public repo.

The oj repository on GitHub

(That screenshot is already out of date. The repo has picked up real contributors since, like William Rudenmalm and André Eriksson, whose recent work is a good chunk of the numbers above.)

So who knows what lies ahead.

Try it

oj is open source and MIT licensed, on crates.io now.

cargo install oj --locked
cd your-vite-app
oj dev

If it doesn’t run your app unchanged, that’s a bug I want to hear about: open an issue with your vite.config.ts and I’ll chase it. That’s the whole promise.