Introducing oj: your Rust native replacement for Vite - Raphael Amorim

Raphamorim

11 min read Original article ↗

tl;dr: If you love Vite keep using it, and skip this article. It’s a wonderful tool, maintained by a wonderful community. This article is basically me trying to fix my own problems (and apparently some coworkers also).

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.

oj is experimental. It already runs real production apps unchanged (I tested it against large ones), but it is not finished. There are gaps: like the ones I know as Svelte support (which I’m actively working on) and the ones I don’t know ha!

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 (basically due to my work reality). 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 doesn’t need a JavaScript runtime to drive it and doesn’t 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.

What every sandbox has to carry approx · per preview environment

500

Node: disk oj: disk cold start

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

The numbers

Note that vite has it’s own bundle mode too (that’s a work in progress), but this was the comparison I ran for some production projects that were migrated. And yes, oj also wins in bundle vs bundle.

Here is oj dev --bundle against Vite 8.2.1’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.1× faster oj 1211ms · Vite 4917ms

Some context on those numbers. The start figures hold as the app grows: on the 10,000-component tree oj cold-starts in about a quarter of Vite’s time, and reload is the one you hit dozens of times an hour. Note that 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.

To be fair to Vite: a well-tuned Vite is faster than a default one, and 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 if you want to reproduce it: node bench/run.mjs 10000.

Memory is a different story. On that same app oj holds around 115MB while Vite sits above 1.5GB, and unlike the startup gap, this one widens as the app grows. That’s what matters most where I actually run oj: thousands of ephemeral preview sandboxes, where every megabyte and every second of cold start gets multiplied.

FWIW: I have seen 12gb vite apps got into 1.8gb with oj usage.

Running a real app

A benchmark on a small app proves nothing. The test I set for myself: take a real, popular open-source Vite app I didn’t write, don’t touch its config, and run it.

That’s harder than it sounds, because real apps use everything Vite offers: regex resolve.alias for monorepo packages, source files outside the app root, TypeScript enums, import.meta.env, plugin virtual modules. Any of those can break a “Vite-compatible” tool, and covering them 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 meant hitting exactly the list above: its monorepo packages resolve 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.

The loop on that app, same machine, from oj dev (or vite, here at 5.0.12, and I assume with latest vite results might have been better on speed, not memory though) 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 cold or warm, on about an eighth of the memory.

One thing doesn’t carry over: oj skips vite-plugin-checker, the plugin that runs tsc in a background worker and overlays type errors in the browser. oj can’t host it (it wants a full Vite dev server that oj doesn’t provide), so it logs skipping unsupported plugin "vite-plugin-checker" on boot and carries on. The app it serves is the same either way, you just don’t get the in-browser type overlay.

^ Keep in mind that Vite’s numbers above include that tsc worker, which oj never starts. I plan to add support for that eventually, but more info about that in the future.

Then I pointed it at something much bigger: Twenty, an open-source CRM whose front-end is around 15,000 modules and uses every hard thing at once: zero-runtime CSS-in-JS via @wyw-in-js, a Linaria/SWC macro pipeline, vite-plugin-svgr, and a pile of CommonJS and UMD dependencies. Getting it to boot and render took another stack of compatibility fixes (CommonJS named-export interop, browser-field stubs, the wyw resolver), and it runs, and it stays ahead:

  cold start warm start memory
oj ~10.2s ~9.2s 1.5 GB
Vite ~11.3s ~10.2s 4.9 GB

oj is faster to first paint on cold and warm, on roughly a third of the memory, and it does that on the harder side of the comparison. This is oj dev with dependencies served unbundled against Vite 8.0.16 with its dependency pre-bundling on, which it always is in dev: Twenty’s first screen pulls close to its entire graph, around 15,000 module requests, and about 9,800 of those are individual dependency files that Vite collapses into a handful up front3 while oj serves one by one. So oj is doing roughly twenty times the requests and still comes out ahead, because on localhost those requests are cheap.

Over a network they aren’t. That’s why oj has partial bundling (experimental, behind a flag): it collapses a dependency’s files into a single request. On a clean React app (router, date-fns, lodash-es) that turns 962 dependency requests into 18, and the app renders identically. On localhost the difference barely matters, but when every request pays a round-trip, which is exactly what a remote or sandboxed dev server looks like, it’s decisive. Same app, time-to-first-render through a latency proxy:

Dependency requests, filling under network latency pbench-app · HTTP/1.1, ~6 connections

unbundled · 962 requests partial bundling · 18

25 ms

unbundled partial bundling time-to-render

round-trip latency before after speedup
0ms (local) 448ms 65ms 6.9×
10ms 2.2s 106ms 21×
25ms 4.7s 195ms 24×
50ms (remote) 8.8s 0.33s 27×

Note: the part I am more interest is memory and here’s why.

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. The origin story is just frustration.

Eventually in my work using vite was almost making my machine fly because of high memory and swap usage! It only happens if you use many agents trying to work in different worktrees though.

At some point it started showing up in my day job at Lovable. People got excited, ofc who would want free memory space. 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).

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.

To be honest 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 changes I measured one by one: persistent caches (codegen, client bundle, SSR loader), a single plugin host instead of two, loader hooks moved in-thread. Each is a modest win on its own, and they add up. All of it lives in the public repo.

Had to study vite and rolldown source code past months, but now it builds ok.

The oj repository on GitHub

(That screenshot is already out of date. The repo has picked up real contributors since)

So who knows what lies ahead.

Future work: experimental cache

The most promising of those, and still experimental, is the persistent cache.

It compiles every module to its final served form, keys that output by a hash of the source, and writes it to a small on-disk store. A warm restart then re-serves compiled modules straight from disk instead of recompiling them, which is most of the difference between a cold boot and a warm one. Vite, for comparison, keeps no cross-restart cache for your app’s own source4: it re-transforms every module, lazily, on each start. Persisting that work is where warm-start speed could come from, but it’s off by default for now while I harden it. Opt in with oj dev --enable-cache (or OJ_ENABLE_CACHE=1) if you want warm starts to skip recompilation. The next paragraph is the reason it’s not on yet.

Persisting it is also where correctness gets tricky, and a real app taught me how. Some Vite plugins don’t just transform a file, they also stash state in memory as a side effect. The clearest case is zero-runtime CSS-in-JS: wyw-in-js (the engine behind Linaria) reads each component’s styled blocks, extracts the CSS, keeps it in an in-memory map, and appends an import of a virtual .wyw-in-js.css file that its own load hook serves back out of that map. Cache the transformed code and nothing else, and a warm restart is a trap: the code still imports the virtual stylesheet, but the plugin’s map is empty because the transform never re-ran, so every one of those imports 404s and the app never mounts.

The fix is to notice exactly those modules and no others. On a warm hit, oj checks whether the cached module imports a path that no longer exists on disk, which is the signature of a plugin-served virtual, and if so it re-runs that module’s transform to repopulate the plugin’s state before serving. Everything else, which is the vast majority, still comes straight from the cache.

However, I am sure I am not covering all cases yet. So, I think needs more testing.

A warm restart: what actually re-runs 336 modules · 9 backed by plugin state

served from cache re-transformed 404 · broke the app

restart

from cache re-transformed 404s result

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.