I think the JavaScript ecosystem is about to hit a wall that has nothing to do with how fast our compilers are. We made transforms fast (Rust everywhere, thanks peeps). What we didn’t make is shareable: every worktree, every CI job, every ephemeral sandbox re-does the same work on another machine.
- Self note: It has been 6y ago that I left JavaScript to Rust, and somehow ended up working with it again because of sandboxes for web applications. I think it’s safe to assume you can’t run away from it ha!
This post is the design of something I wanted to exist: Nix’s ideas, rebuilt at module granularity, living next to the bundler instead of wrapping it. It’s where oj is heading.
Originally I wrote oj because of agents running vite build over and over across git worktrees, each build carrying gigabytes, my machine flying because of memory swapping
(I think I can’t stress enough how annoyed I got when I saw it was accumlating more than 20gb).
It fixed the memory and the cold start. But there was a second thing in that scene that kept bothering me long after, and it took me a while to say it plainly: The worktrees were building the same thing. 
In case you never did agentic work (which is totally fair, I barely did until this year) you probably never used a worktree. Think of a worktree as a physical copy of your repository that shares the same git history, so you can work in it without juggling branches or stashing changes.
An agent opens worktree B to try a change. Worktree B is 99% identical to worktree A, which finished building two minutes ago. Every module the agent didn’t touch, which is nearly all of them, gets parsed, transformed, and linked again from zero.
Multiply that by every worktree on my machine, then by every CI job on a branch, then by every preview sandbox my day job at Lovable spins up, thousands of them, mostly from the same scaffold, and you get an absurd number: the fleet spends almost all of its build compute producing bytes that already exist.
The only input that changed between those builds is the absolute path.
Nix already solved this. And can’t help us.
There is a tool whose entire reason to exist is “build once, substitute everywhere”: Nix. Hash all the inputs, build in a sandbox, put the result in an immutable store, and let anyone who shares your cache download instead of build. It’s the right dream. I went deep on why it doesn’t work for bundlers, and the reasons are mechanical, not cultural1:
- A derivation reruns whole when any input changes. One edited file rebuilds the app; there’s no early cutoff even for byte-identical output.
- Nix wants the complete dependency graph before the build starts. A bundler discovers its graph during the build, import by import, plugin virtual by plugin virtual. The Nix feature that would fix this (dynamic derivations, RFC 92) has been experimental for years, and depends on the content-addressed derivation machinery that Lix just deleted from its codebase as unmaintainable.
- Per-derivation sandbox overhead is fine at 200 packages and fatal at 100,000 modules.
/nix/storeabsolute paths are hostile to everything JavaScript resolution believes in.
The whole Nix-JavaScript ecosystem (node2nix, dream2nix, Canva’s js2nix, buildNpmPackage) spends its entire innovation budget making node_modules reproducible, this is a common pattern I have seen in many projects with same philosophy.
er-module caching of the bundle step has never even been attempted. The closest things that exist are in other languages: Obsidian’s sandstone does per-module GHC builds shared through a binary cache, and nix-ninja does per-compilation-unit C++. Both land on the same conclusion I did: let the language tool stay the planner, and give it a store.
So the answer isn’t “put the bundler inside Nix.” It’s steal what Nix got right (the immutable content-addressed store, substituters anyone can serve and clients verify, keys computed from resolved inputs) and implement it beside the bundler, at the granularity bundlers actually work at: the module.
What Evan Wallace knew
Here is where it gets interesting, because the best argument against this design was written by someone I respect a lot. esbuild’s README says “extreme speed without needing a cache,” and when people asked for a persistent cache, Evan Wallace explained why it will not happen under esbuild’s current API2:
“Plugins are also something that esbuild doesn’t have enough information to cache correctly in a persistent fashion … all esbuild’s API gets is a JavaScript function object. There is also the problem of any dependency being able to open any file at any time and include it in the build. One way for esbuild to automatically implement caching correctly would be for esbuild to be the JavaScript runtime that plugins run in … so it can intercept all file system operations (including the loading of code), as well as all other sources of non-determinism. But that would be a very different, invasive API change.”
He’s right. Plugins are opaque effectful functions. A cache that doesn’t see what they touch is a cache that lies to you eventually. Vite closed three transform-cache PRs on exactly this rock3, and every webpack-family filesystem cache pushes the problem onto the user as buildDependencies config you will get wrong once and remember forever.
But read the quote again, slowly. “For esbuild to be the JavaScript runtime that plugins run in… so it can intercept all file system operations.” That invasive change esbuild can’t make?
oj already is that runtime. Since 0.2.0, Vite plugins don’t even get their own process: JavaScript workloads run in-process on Deno isolates oj embeds, no more JS sidecars (on one internal app that took 6 processes down to 2 and 4.1 GB down to 1.5). V8 lives inside oj’s binary, executing code oj resolves and loads, and every filesystem call a plugin makes crosses an ops boundary oj compiles. “Intercept all file system operations” stops being an invasive API change and becomes bookkeeping: record every file a load or transform hook reads into the entry’s trace. And because oj owns the compiler too, it can make cached artifacts path-free at the source instead of scrubbing them after.
That asymmetry is the whole reason I believe this design belongs in oj and not in a wrapper around any existing tool.
The proof it can work is ten years old
Meta has been running a content-addressed, machine-shared, per-module transform cache in production for years. It’s called Metro, and its caching doc describes the exact deployment I want for sandboxes: CI populates an HTTP store, thousands of engineers read from it, and the store itself is dumb: GET/PUT of compressed blobs by hash4.
Metro gets away with it because of choices that sound boring and are everything: keys are content hashes (never mtimes) over project-relative POSIX paths (never absolute), the options are serialized in, and, my favorite, the hash includes the transformer’s own source code, so upgrading the toolchain invalidates everything it should. Nothing machine-specific ever enters a key, so worktree A and worktree B produce the same key by construction. And when something misses, one module re-transforms. Not a graph. Not a rm -rf .parcel-cache.
Metro caches one hop (the transform). The vision below is Metro’s discipline applied to the whole pipeline, plus the piece Metro punts on (plugins declare their own cache keys, which is how you get silently poisoned) replaced with tracing.
The design: a store, a key, a trace
I’m calling the whole thing what it is: the oj store. Same naming convention as the Nix store and pnpm’s store, and the word is doing work: a cache is an evictable accelerator you can lose; a store is the durable place builds are restored from. Three pieces, all content-addressed, all boring on purpose5:
1. A CAS. Immutable blobs keyed by blake3. Transformed modules, sourcemaps, chunks, prebundled deps. Two worktrees, or two thousand sandboxes, holding the same file store it once.
2. An action cache. key → outputs, write-once. The key for transforming one module is a hash over: oj’s version, the mode, the env defines, the plugin chain (package versions from the lockfile plus canonicalized options: Metro’s trick, done for the plugin instead of trusting it), the resolver config, the lockfile epoch, the module’s root-relative path, and its content hash. Nothing else. Notice what’s absent: any absolute path, any timestamp, any machine identity.
3. A trace. For work whose inputs are discovered mid-flight, which is any work touching plugins or import.meta.glob, the entry also records what was actually read, with digests: files the plugin opened (seen by the fs interposition), resolutions used, glob listings. On a hit, oj re-verifies the trace against the local worktree (re-hash a handful of files, no JavaScript executed) before serving. This is ccache’s “direct mode” manifest, generalized, and it’s the principled version of the surgical virtual-module check I described in the oj post.
One invariant rules all of it, and it’s Evan’s, enforced structurally instead of hoped for: a cache entry may depend on nothing that is not in its key or its trace. Plugins that break it (read the network, inject Date.now()) get detected by sampled re-execution and demoted to uncached. One impure plugin should cost you its modules, not the cache.
And one rule with no exceptions, because it’s the single most common way every bundler cache before this one died6: no absolute path may appear in any key, trace, or cached artifact. Files get canonical relative identities: ws:src/App.tsx inside the root, pkg:react@19.1.0/index.js for dependencies (which is what lets two different apps on the same React share cache entries), content hashes for everything else.
Here’s what that buys a worktree, concretely. This is a warm boot of worktree B, which has never built anything, against a store populated by worktree A:
The sweep speed in that graphic is the point: worktree B doesn’t build, it checks. Re-hash the files a trace names, confirm the digests, write the outputs into place. No JavaScript runs for the 333 modules you didn’t touch. And there’s a bonus the diagram undersells: because the key for a chunk is computed over its members’ output digests, editing a comment re-transforms one module, produces the identical output digest, and everything downstream stays green. Nix never gave you that cutoff; content addressing gives it for free.
On one machine, that’s the whole worktree problem dissolved by a global store directory: no server, no network, nothing to deploy.
I wanted to feel that, not just design it, so I ran the silly version for real: twenty checkouts of Excalidraw, one store, a release build of oj, one laptop. Worktree one compiled the app and populated the store: 1,994 modules crawled in 507ms, 1,761 entries written. The other nineteen booted from it. Every one of them wrote zero new entries to the store, nineteen boots in a row, and their crawls averaged 109ms, 4.6x faster, worst case 309ms. Then I started all twenty dev servers at once: 20/20 serving, 3.9 GB of memory total (197 MB each, and remarkably flat: min 193, max 203), on 134 MB of shared disk for the whole fleet’s cache. Per-app caching would have copied that twenty times.
Memory is the part oj already fixed per-process, though it turns out the store helps there too: a boot that restores never runs the compiler, and it settles about 28% lighter than the boot that compiled (198 MB against 274 MB on this app, measured on both). What the store changes structurally is everything that used to be per worktree: twenty worktrees used to mean twenty compiles of the same graph and twenty copies of the cache; now it is one compile, nineteen restores, one 134 MB directory. And the comparison that started this post: under Vite this app measures about 2.4 GB per dev server, so twenty of them would want ~48 GB against the 3.9 GB I measured, and the fifty-worktree version of that arithmetic is ~120 GB against ~10. The shape of it: memory scales with the servers you actually run, compute scales with the edits you actually make, and the cache doesn’t scale at all.
Who decides the granularity?
A colleague asked me the right hard question about this: in Bazel, granularity is a choice you make. You can be lazy and have one top-level BUILD file, and every edit rebuilds everything; or you write BUILD files per package and Bazel can be precise. Nothing here is declarative, so who picks the units?
The answer is that the granularity isn’t decided, it’s inherited. Bazel needs BUILD files because to Bazel every action is an opaque subprocess, so a human has to partition the graph and declare each partition’s inputs, and humans pick that partition badly all the time. A bundler doesn’t have that freedom or that burden: ESM already partitioned the app. The module is the unit the language gives you. It’s the unit the resolver discovers, the unit HMR invalidates, the unit a transform is a pure function of. oj doesn’t pick a granularity per app; it inherits the one the import graph defines.
The actual rule underneath is about observability, not size: cache at the largest step whose inputs you can enumerate soundly. Because oj is the runtime, a module transform’s inputs are observable (source hash, config digests, every file a plugin hook reads, traced at the fs layer), so app code caches at module grain. Where oj can’t see inside a step, which today means rolldown running as native code, the unit grows until its inputs become enumerable again: the whole input closure, keyed like a Bazel action with everything declared. Fine grain where we can trace, coarse grain where the step is opaque. Dependency prebundles sit in between, keyed per lockfile subtree, because that’s their real invalidation boundary (and it’s what lets two different apps share them).
And the grains compose instead of competing, because the coarse keys are computed over the fine units’ output digests, not over sources. The chunk and closure layers are merkle nodes over the module layer. That’s why the one-top-level-BUILD-file failure mode can’t happen here: the coarse units were never independently declared, so an edit that doesn’t change a module’s output can’t invalidate anything above it.
There’s a second reason Bazel-style fine grain never worked for JavaScript, and it isn’t the opacity: per-unit overhead. A Bazel action costs a process spawn and sandbox setup, which is why per-file targets die at scale, and it’s the same reason per-module Nix derivations die. In-process, the per-unit cost is a hash lookup. Owning the runtime doesn’t just make inputs observable; it pushes the overhead floor down to where the language’s natural unit becomes affordable.
So: not declarative, but not oj guessing either. Observed where we can trace, declared-and-verified where native code makes us blind (rolldown’s own module graph output is its declaration, and we digest-check it at use time), and never trusted without a way to catch the lie. Nix and Bazel are declare-then-trust. This is observe-then-verify.
But how far does a single BUILD file get you?
The same conversation had a second question worth answering in public: if the big win is incrementality, how far do you get with the dumb version, one generated BUILD file at the top of each project and a remote cache?
Genuinely far, for exactly one case. If the input closure is byte-identical (thousands of sandboxes booting the same scaffold, CI re-running an unchanged app), a coarse closure key hits and you skip the entire build. That’s real, it’s the cheapest layer to ship, and I’m not pretending otherwise: the design’s coarse layer is that, a merkle key over the whole input closure mapping to a whole-output manifest. It ships first.
Where the dumb version stops:
- Coarse caching is a step function; fine caching degrades gracefully. The coarse hit rate is the probability the closure is exactly identical, which drops to zero on the first edit, and every sandbox I care about exists to be edited. One changed file out of 18,000 pays the full build. With module grain, cost scales with the diff: 3 edits means 3 transforms and a relink. That’s the precise statement of the advantage, and it’s stronger than “incremental rebuild”: cost proportional to edit distance instead of app size. A fleet where everyone edits 0.1% of their app gets roughly nothing from the coarse cache and roughly everything from the fine one.
- The dev loop has no artifact to cache. A BUILD target caches a build’s output tarball. A dev server isn’t a tarball; it’s a lazy, on-demand transform stream feeding HMR. There is nothing for a task-level cache to restore that makes
oj devboot warm. The module store is the only cache shape that serves dev, and dev is where sandbox time actually goes. - No sharing across apps. A closure key is all-or-nothing per project. Module and prebundle grain share across different apps: every scaffold on the same react version hits the same
pkg:entries. For a fleet of near-identical-but-not-identical apps, that’s most of the bytes. - You still owe the hard work anyway. For the coarse tarball to be correct you must enumerate the closure (source, the config’s import graph, the lockfile, env: hermeticity by declaration, the thing JavaScript tooling is worst at), and for it to be usable it must be relocatable, or you’re back to restoring another worktree’s absolute paths. So the single-BUILD-file plan still requires the path-independence and input-fingerprinting work. At that point you’ve built everything except the one layer that helps after the first edit.
- And if the wrapper is literally Bazel, every ephemeral sandbox now carries the Bazel server, its memory and its analysis phase just to compute a hash and ask a cache. Analysis can cost more than the build it’s trying to skip. A closure hash and one HEAD request do the same job with none of the machinery.
So the honest scorecard: the coarse layer captures the “nothing changed” case and should exist. The fine layer is what makes the other 100% of sessions, the ones where someone actually works, cost what they changed instead of what they own.
The distributed part
But the reason I call this a vision for distributed systems is what happens when the store grows a wire. The protocol I want is deliberately tiny. Six endpoints, shaped like Metro’s store and Nix’s substituters had a child:
GET /v1/cas/{blake3} → bytes (zstd, immutable, cache-forever)
HEAD /v1/cas/{blake3}
PUT /v1/cas/{blake3} → 201, or 409 if it exists
POST /v1/cas/missing → which of these digests should I upload?
GET /v1/ac/{namespace}/{key} → outputs + trace, or 404
PUT /v1/ac/{namespace}/{key} → 201, or 409 (entries are write-once)
The boring shape is load-bearing. Write-once action-cache entries and separate read/write tokens aren’t nice-to-haves: Nx published a 9.4-severity CVE this year (CREEP) showing that any shared build cache with first-write-wins and a single credential can be poisoned by anyone who can open a PR7. So: sandboxes and PR builds get read-only tokens, trusted builders write, entries are signed (Nix’s model), and a poisoned or impure entry costs you one module, never the graph.
Now the fleet math. A sandbox platform bakes its scaffold once (one trusted builder runs the template and populates the store) and every sandbox spawned from it cold-starts by verifying and materializing. Dependencies are even better: their cache key is the lockfile subtree, not the app, so every app on the same React version shares one prebundle entry. Here’s what that does to total transform compute as a fleet scales (projected from oj’s current numbers, not a benchmark; the point is the shape, not the digits):
The line I keep coming back to when I look at that chart: today, fleet build compute scales with the number of sandboxes. With a store, it scales with the number of edits. Those are different asymptotics, and everything else (the cost, the cold-start latency, the energy, honestly) follows from which one you’re on.
And here’s where I actually want to take this at Lovable: host the immutable hashes. Not per worktree, not per clone of a repo, not even per project: a platform-level store of content-addressed build outputs that every app reads from. Because dependency entries are keyed by pkg:name@version plus config, they aren’t yours or mine, they’re just facts: there is exactly one correct prebundle of react 18 under a given config, and once anyone has produced it, no app anywhere in the fleet should ever produce it again. For the kind of apps a platform like Lovable hosts, that’s easily more than 80% of the bundled code coming for free, forever. A new project that uses react 18 doesn’t bundle react; it looks up a hash and links against an artifact that has existed for months. That step goes from seconds to under a millisecond, and it never comes back.
Why every previous attempt broke (and the rule that falls out)
I read a lot of postmortems for this. webpack’s filesystem cache: “we store absolute paths in cache, so moving/copied doesn’t work” (a maintainer, verbatim). Parcel 1 died on absolute paths too; Parcel 2 fixed the paths and then its monolithic request graph meant one wrong invalidation edge corrupted everything, which is why rm -rf .parcel-cache is folklore. Turborepo restores next build outputs verbatim, with another worktree’s absolute path inside them. Turbopack has the most sophisticated incremental engine of all of them and its persisted cache is still a same-directory, restore-in-place artifact6.
Every one of these is the same lesson wearing different clothes: caches designed as “the same directory, evolving over time” meet a worktree, which is a discontinuous history at a different path, and produce either misses or lies. Content-addressed lookup doesn’t have a notion of history to violate. That’s not an optimization; it’s the property that makes the whole thing correct.
Where this starts
I’m not announcing a product, I’m showing you where oj is pointed. The honest status: oj ships no persistent module cache today. The experimental one I wrote about in the oj post is gone; studying it is where most of this design came from. Its keys were per-app-directory, its artifacts had absolute paths baked in (sourcemaps, dev JSX filenames, rewritten import URLs), and its key didn’t fingerprint the plugin chain, and hardening a design you already know is wrong is how you end up maintaining webpack’s cache. The oj store described here is the replacement, and the build order is roughly: one global store with relative keys first (that alone makes worktrees on one machine share almost everything), then path-free artifacts and the strengthened key, then traces with the fs interposition in the plugin isolates, then the wire protocol. The surface I’m aiming for is one flag: oj dev --store while it hardens, on by default once it has earned it, --no-store forever.
If some of it sounds ambitious: it is, and I said the same about running Excalidraw and a 15k-module CRM unchanged. It’s a stack of small, measurable changes, same as last time. And if you’ve operated a build cache at fleet scale and have scars to share, or you think a step here is wrong: open an issue. Being told precisely why I’m wrong is the fastest way I learn.
The JavaScript ecosystem spent five years making single builds fast. I think the next five are about never doing the same build twice.
-
The mechanics: input-addressed derivations rerun whole on any input change (jade’s “The postmodern build system” is the best writeup, including Lix removing CA derivations); garnix on incremental CI on the granularity and sandbox-overhead problems; RFC 92, dynamic derivations for why the static build plan is the core mismatch, and fzakaria’s early look for their current state. ↩
-
Evan Wallace in esbuild#3063, “Rebuild for CI / persistent cache”. The same comment makes a subtler point I’m not dodging: intercepting the filesystem isn’t the whole job; a correct runtime must catch “all other sources of non-determinism” (his example: a plugin injecting the current date). That’s what the sampled re-execution check is for: run a small percentage of cache hits anyway, compare digests, and demote any plugin that diverges to uncached. ↩
-
Vite’s transform-cache attempts: #1309 (closed not-planned), #4120 and #10671 (closed over invalidation-correctness doubts; the team was, reasonably, “unsure of the current caching invalidation logic being correct for all cases”). ↩
-
Metro’s Caching.md describes the store chain and the CI-populates/developers-read deployment: “This is how we use Metro to build React Native apps at Meta.” The key composition is in
getTransformCacheKeyandTransformer.js; notenormalizePathSeparatorsToPosix(projectRelativePath)and the content hashes of the transformer’s own implementation files. ↩ -
For the theory-inclined: in Build Systems à la Carte terms, a bundler is a suspending scheduler with monadic (discovered-during-build) dependencies, and the paper’s named optimum for cloud-caching that combination is “Cloud Shake”: suspending scheduler + constructive traces. The trace-verification scheme here is ccache’s direct mode manifest, generalized beyond
#include. ↩ -
webpack: maintainers on why the cache can’t move machines and absolute paths in node_modules records. Parcel 1’s path-keyed cache: #2664; Parcel 2 made relocatability an explicit goal (RC post) and the graph-fragility folklore lives in #8502. Turborepo restoring another worktree’s absolute paths: #3185. Turbopack’s persisted-cache staleness across restored builds: next.js#87283. Rspack is the first webpack-family bundler to ship an explicit
portable: truecache option, which tells you how new this awareness is. ↩ ↩2 -
CVE-2025-36852 (CREEP), which pushed Nx’s self-hosted cache spec to mandate write-once entries (409 on conflict) and led them to deprecate their own shared-bucket plugins. Gradle’s docs have quietly said “only CI pushes, developers pull” for years for the same reason. ↩