Open-source project
lovablelabs/oj avatar
lovablelabs/oj

oj wins memory against Vite and loses reload at ten thousand components

An experimental Rust-native build tool for React apps.

592 stars27 forksRustMIT

At a glance

What is it?
An experimental Rust dev server with an embedded V8 that streams SSR through a Node module runner and understands Vite configs. Its own benchmark tables concede the reload column and name a third tool that beats it, and the memory column is reported two ways with a third deliberately omitted.
Who is it for?
Use it if your bottleneck is memory across many concurrent dev servers, since that is the column it wins at every size and by the largest margin, and if you can accept losing reload latency at very large component counts. Treat the start-up numbers carefully: the win is against Vite's default dev mode, not against Vite's bundled mode, which starts faster than oj at all three sizes.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 2, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The tables concede reload and introduce a faster rival

Three tools are compared: oj, vite in its default dev mode, and vite-fbm, described as Vite's experimental bundled dev mode. The comparison is generated fanout React component trees at 1,000, 5,000 and 10,000 components, measuring save-to-paint with Playwright against Vite 8.2.1 on an M-series Mac, with p50 and p95 over five cold-and-warm restart cycles and ten HMR edits.

Cold start at 10,000 components is 4168/4487ms for oj against 6983/7413ms for vite and 1593/2130ms for vite-fbm. So oj is about 1.7x faster than Vite's default mode and about 2.6x slower than the bundled mode. The claimed win is real and it is against one of the two baselines.

Reload tells the other story. oj is 232/238ms at 1,000 components against vite's 229/232ms, a tie; 1037/1056ms against 1002/1099ms at 5,000, again a tie; and 2934/3115ms against vite's 2400/2454ms at 10,000, where oj is behind. Against the bundled mode reload is not close at any size, 307/329ms against oj's 2934/3115ms at 10,000.

HMR is described as a wash across all three, which holds for oj against vite and does not hold against vite-fbm, where HMR is 60/69ms against oj's 59/184ms. Read the table rather than the summary line.

Memory is reported two ways and a third way is omitted on purpose

Memory is the claim the project makes most strongly, and the methodology is where the honesty is.

Two numbers are given for every row: resident set over the whole process tree, and macOS physical footprint, which excludes clean and reclaimable resident pages and therefore bounds retained memory. At 10,000 components oj is 231MB by tree RSS and 190MB by footprint, while vite is 1465MB and 1126MB, and vite-fbm is 1761MB and 1229MB. The ratio quoted is roughly 3x at 1,000 components growing to 6x at 10,000, and it holds in both columns rather than only the flattering one.

A third column does not exist, and the page explains why. Forced-GC RSS is not reported because it cannot be measured symmetrically: Node exposes an external GC handle through its inspector, while embedded V8 does not. The reference figure is given anyway, with the note that forcing GC before measuring lowered vite's tree RSS by roughly a third in the authors' runs, still well above its footprint. So the gap survives the most favourable correction available to the baseline.

The methodology was also revised after a public issue, with the page crediting corrections to issue 202. A benchmark that names the issue where its method changed is doing something most published numbers do not.

Production builds are a separate claim: `oj build` and `vite build` land at parity on output size, on the same engine, Rolldown.

Embedding V8 fixes the platform list at eight targets

The tool embeds V8, and the supported targets are therefore the ones V8's bindings ship prebuilt static libraries for. The list is Linux glibc on x64 and arm64, Linux musl on x64 and arm64, macOS on x64 and arm64, and Windows MSVC on x64 and arm64.

That is eight combinations, and it is a hard boundary rather than a preference. Anything outside it means building V8 from source, which is a different proposition from `cargo install`. Notably absent are 32-bit targets and the Windows GNU toolchain, so a project pinned to either would need a different plan.

The motivation for the whole project is stated in terms of where the cost sits: running many builds under Vite gets expensive in CI, in agents and in multi-tenant setups, and oj optimises for memory and cold start rather than for anything else. The stated design constraint is that it should run real production React apps with no changes to their source.

Installation is a normal cargo install, or a Nix invocation that fetches the repository directly:

sh
cargo install oj --locked

The port for the dev server is 5199, and a production build goes into `./dist`.

Config adoption comes with a stated precedence

Two routes exist for plugins, and the second one is the interesting one.

You can drop an `oj.plugins.mjs` at the app root that default-exports a plugin array. Or you can let oj read the app's own `vite.config.ts`, `.js` or `.mjs` and take its `plugins` array directly, which is what makes oj usable on an existing project without a rewrite.

Loading a TypeScript config has its own fallback: it goes through Vite's own config loader when Vite is installed, and is bundled with the app's own esbuild otherwise. So a config that imports local `.ts` files works in both cases.

From a `vite.config` oj also adopts `base`, `server.port` and `server.host`, `define`, and `resolve.alias`, and the rule is that it takes a field only when its own config leaves it unset. Alias entries then resolve alongside `paths` from tsconfig, in both `oj dev` and `oj build`.

That precedence is the thing to test on your own project, since the failure mode is silent: a field set in both places resolves to oj's value without a warning, and a project with an unusual base or alias setup is exactly where that will surface.

SSR runs in a Node module runner instead of a rebuilt bundle

There is an SSR mode, enabled with an entry file in dev and a flag in production: `oj dev --ssr src/entry-server.tsx`, and `oj build --ssr` for a build.

Two details define the approach. The HTML is streamed out with `renderToReadableStream` rather than buffered, so the response starts before the tree is complete. And the client hydrates through the normal dev pipeline, which is what keeps Fast Refresh and HMR working over a server-rendered page instead of degrading to a full reload whenever the server output changes.

Underneath, the server modules run in a small persistent Node process described as a module runner built on `vm.SourceTextModule`. It re-evaluates only what changed rather than rebuilding a bundle per request, which is where the cold and warm start numbers come from.

TanStack Start is supported the same way, with no source changes, and oj detects it from the app itself rather than from a flag. A target directory is passed as an argument, `oj dev web` or `oj build web`, and the build emits a Node `server.mjs`, a Cloudflare `worker.mjs` with `nodejs_compat`, content-hashed client assets and prerendered routes. The project's own documentation site under `www/` is a TanStack Start app built by oj and served from a Cloudflare Worker.

A Deno fork exists because deregistration went quadratic

The most specific piece of engineering in the manifest is a pair of forked crates and the reason for them.

oj maintains forks of `deno_napi` 0.190.0 and `deno_runtime` 0.267.0, published to crates.io under those same names, carrying one patch: a finalizer-registry change. Upstream keeps its entries in a Vec, and deregistration is a linear scan followed by a shift, which goes quadratic under garbage collection when an addon holds hundreds of thousands of live references. The stated symptom was that it dominated one-shot rebundle child processes, with rolldown bundling a large app as the example.

The publishing trick is what makes this unusual. The forks go out under the upstream names, and consumers rename them back through the dependency key so that `use deno_napi::` and `use deno_runtime::` keep working unchanged. A published build of oj resolves them by version from crates.io rather than by path, which means someone who takes a dependency on oj inherits the patch without editing anything.

The workspace itself is fifteen crates: a compiler, resolver, graph, cache, CSS, environment, config, server, JS and Wasm layer, plus the four Deno pieces and the `oj` binary itself. Nine of the internal crates are declared as workspace dependencies with the same version and a path, and the workspace version is 0.2.16.

The lint target excludes four crates and the formatter is fetched by checksum

The Makefile has two recipes, `lint` and `fmt`, and both carve out the vendored code.

The reason is in the comment above them: the vendored Deno forks keep upstream sources verbatim, so they are formatted with upstream's rustfmt configuration and are not linted as targets. Clippy therefore runs across the workspace and all targets while excluding `oj_deno_napi`, `oj_deno_process`, `oj_deno_runtime` and `oj_deno_snapshots`, with warnings promoted to errors. The same forks, the `reference/` directory and the end-to-end fixtures are left out of the JavaScript formatter as well, which `.oxfmtrc.json` records.

The formatter itself is an unusual choice. It is not installed through a package manager: the recipe calls `tools/fetch-oxfmt.sh`, which pulls a pinned oxfmt release binary with its version and checksums into `.tools/` on first use, needing neither Node nor npm. The script runs inside the recipe so a failed download or a checksum mismatch fails the target rather than producing a silent no-op.

The release cadence suggests this is being worked on heavily. v0.2.14 on 2026-09-29, v0.2.15 on 2026-09-30 and v0.2.16 on 2026-10-01, three patch releases in three days, with the last push on 2026-10-01 and the repository not archived. The page's own list of miscellaneous commands ends mid-word on its final line, so one of the documented commands is incomplete.

Editorial conclusion

Use it if your bottleneck is memory across many concurrent dev servers, since that is the column it wins at every size and by the largest margin, and if you can accept losing reload latency at very large component counts. Treat the start-up numbers carefully: the win is against Vite's default dev mode, not against Vite's bundled mode, which starts faster than oj at all three sizes. And check the target list before adopting, because embedding V8 means the supported platforms are exactly the ones V8 ships static libraries for.

Frequently asked questions

What is oj?

An experimental Rust build tool and dev server for React apps, MIT licensed and published to crates.io. It embeds V8, optimizes for memory and cold start, and is meant to run production React apps with no changes to their source.

How do I install the oj CLI?

With `cargo install oj --locked`, or by running the project through Nix with `nix run github:lovablelabs/oj -- dev`. The dev server listens on port 5199 and `oj build` writes production output into ./dist.

Is oj faster than Vite?

Against Vite's default dev mode, yes, by roughly 1.7x on cold start at 10,000 components. Against Vite's experimental bundled dev mode it is about 2.6x slower on start, and on reload it ties Vite at 1,000 and 5,000 components and trails it at 10,000. The decisive win is memory.

How does oj handle memory measurements?

Two numbers are reported for every row, whole-process-tree resident set and macOS physical footprint, and the project explains why a third is missing: forced-GC RSS cannot be measured symmetrically because Node exposes an external GC handle through its inspector while embedded V8 does not. Methodology corrections are credited to issue 202.

Which platforms does oj support?

Only the targets V8's bindings ship prebuilt static libraries for: Linux glibc and musl on x64 and arm64, macOS on x64 and arm64, and Windows MSVC on x64 and arm64. Anything else means building V8 from source.

Does oj work with existing Vite configs?

Yes. It can read vite.config.ts, .js or .mjs and take its plugins array, adopting base, server.port and host, define, and resolve.alias for any field its own config leaves unset, with aliases resolving alongside tsconfig paths in both dev and build. A TypeScript config is loaded by Vite's loader when Vite is installed and bundled with the app's esbuild otherwise.

Official sources

  1. Issues
  2. License: MIT
  3. lovablelabs/oj on GitHub
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/lovablelabs-oj.svg)](https://hysenlabs.com/projects/lovablelabs-oj)