Where 41 MB of Node went, and why OMD mods run on embedded V8
An idle Oh My Diff (OMD) mod runner used 41.1 MB of memory under Node 24. The embedded V8 runner that replaced it,
omd-runner, uses 6.2 MB measured the same way, and it goes from spawn to ready in 26.4 ms instead of 48.5 ms. The
switch shipped in OMD 1.0.1 on 2026-10-20 in
#266. Before writing any of it, I measured where Node's memory went.
Bare Node was 11.5 MB. A single feature, warming Node's TypeScript stripper at spawn, added 23 MB to every runner.
The implementation described here is OMD 1.4.0 on Codex 0.166.0. The performance numbers are historical. I took them on
2026-10-20 on one Mac against the Node-era runner and the first release build of omd-runner. Mods run only on
macOS, so nothing here was measured on Linux.
How mods run
OMD patches Codex with a host that runs TypeScript mods. The host never runs mod code in its own process. It starts
a runner under macOS Seatbelt with a deny-by-default profile and sends it one line of JSON per callback over stdin.
The runner prints {"ready":true} when it can take work, runs the callback, and answers on stdout. Host operations
a mod asks for come back to the host as requests, and the host checks each one. In 1.4.0, the host looks for
omd-runner beside its own executable and refuses to run mods if it's missing:
let host = std::env::current_exe()?.canonicalize()?;
let runner = host.with_file_name("omd-runner");
That is from process.rs. Under
1.0.0 the same slot ran node --eval with a 1,214-line runner.mjs.
Runner memory adds up because there are several runners. The host's pool keeps one pre-spawned spare (up to three
while mods are busy) and up to four warm runners, each kept for 60 seconds. An end-to-end run of the 1.0 rc.2
default mod set in a scripted terminal session with a mock model measured 9 runners using 389 MiB in total, against
38 MiB for the codex process itself.
Measuring the Node runner
Replacing Node would affect the sandbox, store format, installer, tests and docs, so I first measured how much memory the runner used and which parts of Node accounted for it.
The harness spawned runners exactly as the host does: same Seatbelt profile, same flags, same line protocol. It drove
four real first-party mods: the omd_statusline terminal view (host calls, three modules), the omd_privacy
model_send interceptor (Web Crypto HMAC), the omd_mods /mods command, and the omd_tool_lines render
interceptor in serve mode. Each figure is the median of 3 interleaved rounds. The machine was my M-series Mac with 10
cores and 32 GB, with a load average of 3 to 8 from other work. Node was 24.18.0.
Memory is the physical footprint from proc_pid_rusage (ri_phys_footprint). That's the figure the host's memory
watchdog reads and the one Activity Monitor shows. RSS counts shared binary text in every process, so the same idle
Node runner was 77 MB RSS and 41 MB footprint. I use footprint everywhere below.
A fresh callback is the first one a runner process handles. A reused callback runs in a runner that the host kept for the same mod.
The Node runner used this much memory and time:
| Node 24 runner | Measured |
|---|---|
| Footprint idle / after one callback / reused | 41 / 47 to 52 / 62 MB |
| Cold spawn to first reply | 53 ms |
| Reused callback p50 | 1.2 to 1.6 ms |
| Warm render p50 | 0.15 to 0.22 ms |
| Idle CPU | 0 |
A reused callback already took about 1.5 ms, and a warm render took under 0.25 ms. A replacement had to reduce memory use while keeping those timings.
Node also cost users an install step. The runner refused anything but Node 24.18 or a later 24.x, while Node 26.12
was out. Node 26 removed the transform and sourceMap options of stripTypeScriptTypes, which the author tooling
used, and vm.SourceTextModule, which gave each mod its own module graph, was still experimental behind
--experimental-vm-modules in Node 24.
Where the Node runner's memory went
I started bare Node and added the runner’s pieces one at a time:
| Stage | Footprint |
|---|---|
| Bare Node | 11.5 MB |
Plus node:vm and a context |
11.6 MB |
| Plus the runner prelude, without TypeScript | 18.1 MB |
Plus warming stripTypeScriptTypes |
41.1 MB |
The runner warmed stripTypeScriptTypes at spawn,
which loaded amaro, the WebAssembly build of swc that Node uses to strip types. That one warm-up was 23 MB per
runner, more than half the total.
One option was to keep Node and move TypeScript stripping into the Rust host with swc, removing amaro from each runner.
Alternatives
Tuned Node measured 17.5 MB idle, 20 to 22 MB after a callback, 42 MB reused, and 37 ms from spawn to first reply.
That is a 57% cut in idle footprint for a small change, but it kept the Node install requirement and the experimental
vm module API.
I judged the other candidates against what the Node runner already enforced: a separate realm per mod with no path to
the host's Function, no code from strings, no dynamic import, a heap cap with the existing out-of-memory error, the
Seatbelt boundary, and Intl, which first-party mods use. Dropping the install requirement was the goal.
| Option | Idle footprint | Cold start | Blocking problem |
|---|---|---|---|
| Node 24 (1.0.0) | 41 MB | 53 ms | install requirement, 41 MB |
| Node, tuned | 17.5 MB | 37 ms | keeps the install requirement |
| Embedded V8 prototype | 4.4 MB | 28 ms | none; +49.7 MB artifact |
| Bun 1.4.2 | 10.7 MB (without TypeScript) | not measured | heap not capped |
| Deno 2.9.7 | 11.6 MB (bare) | not measured | runner fails on missing vm API |
| QuickJS-NG 0.17 | not prototyped | not measured | no Intl |
The runner without TypeScript idled at 10.7 MB on Bun. With BUN_JSC_forceRAMSize=64MiB set, an allocation loop
still reached 733 MB of heapUsed, and Bun has no stripTypeScriptTypes. Deno enforced the heap cap and honored
codeGeneration.strings: false, but its node:vm lacks createCachedData, so the existing runner failed, and a
deno compile binary was 67.7 MB. QuickJS-NG is small, can leave out eval and has a memory limit. It has no Intl,
and omd_tool_lines and omd_mods call Intl.Segmenter, so I didn't build a prototype. Its interpreter is also
about twice as slow as V8 in jitless mode, going by published benchmarks. I ruled out Boa and Hermes on paper. Boa
is about six times slower than QuickJS-NG, and Hermes has no runtime ES modules.
deno_core would have given me a module loader and ops on top of V8. Codex pins v8 150.4.0, and a process can't
link two V8s. Only deno_core 0.409.0 accepted that V8 line directly, and 0.412.0 reached it through a second crate,
so I would have had to track both deno_core releases and Codex’s V8 version. I didn't prototype it, and I didn't evaluate a
WebAssembly engine or a Node single-executable app.
The V8 prototype was the only option that met every requirement and dropped the install requirement. Its security probes, run under the same Seatbelt profile against Node, matched on 15 of 17. I picked it on 2026-10-20 with three rules: strip TypeScript with swc in strip-only mode, keep no Node fallback, and add no compatibility layer.
Runner design
The Node runner had two halves. The mod realm ran the bootstrap, dispatch and step scripts that call a mod's handlers.
The host realm was hand-written JavaScript with frozen intrinsics that built each mod's context and module graph. I
extracted the mod realm nearly verbatim into
bootstrap.js,
dispatch.js and step.js, and rewrote the host realm in Rust. With no host-realm JavaScript left, the hand-frozen
intrinsics went away too.
The isolate setup in
main.rs is most of the security
model:
const HEAP_LIMIT: usize = 64 << 20;
const FLAGS: &str = "--single-threaded --no-lazy --disallow-code-generation-from-strings --no-maglev --no-turbolev \
--no-turbo-inline-array-builtins";
v8::V8::set_flags_from_string(FLAGS);
v8::V8::initialize_platform(v8::new_single_threaded_default_platform(false).make_shared());
v8::V8::initialize();
let mut isolate = v8::Isolate::new(
v8::CreateParams::default().set_max_old_generation_size_in_bytes(HEAP_LIMIT),
);
isolate.set_oom_error_handler(out_of_memory);
isolate.set_allow_wasm_code_generation_callback(no_wasm);
isolate.set_host_import_module_dynamically_callback(dynamic_import);
The heap cap is the same 64 MiB as Node's --max-old-space-size=64. out_of_memory writes Node's exact "Reached
heap limit" line and exits with 134, so the host still maps it to mod_out_of_memory. no_wasm returns false, and
dynamic_import returns a promise rejected with an error built in the mod's realm. A watchdog thread calls
terminate_execution when a callback passes its hook_budget_ms.
Each callback gets a V8 context that has never run mod code. prepare builds the next one while the runner is idle
and turns off string code generation on it with context.set_allow_generation_from_strings(false). The module graph
is built with instantiate_module and a resolver over the package's assets, with JSON assets as synthetic modules.
TypeScript stripping
Node strips TypeScript with swc, and so does the runner. The crate is swc_ts_fast_strip 59.0.1, the same swc code
amaro wraps, called in strip-only mode:
let options: swc_ts_fast_strip::Options = serde_json::from_value(serde_json::json!({
"module": true, "filename": filename, "mode": "strip-only", "deprecatedTsModuleAsError": true,
}))
Strip-only mode replaces each type annotation with whitespace in place, so a stack frame in stripped output points at
the same line and column in the author's .ts file. It rejects syntax that needs code generation, such as enum and
parameter properties, which is Node's rule too. strip maps swc's error codes to Node's
ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX and ERR_INVALID_TYPESCRIPT_SYNTAX and prints a Node-style code frame. The
prototype stripped a 23 KB file in 0.5 ms. I also looked at oxc. It re-prints the output, which would have needed
source maps, and it has no erasable-only mode.
I also changed the type checker. mods:check moved to the Go-based TypeScript 7 compiler. On mods/,
tsconfig.json went from 1.09 s to 0.22 s and tsconfig.author.json from 0.68 s to 0.23 s, best of 3.
Implementation problems
Web Crypto was the biggest piece. Under Node, mods got crypto.subtle for free. The runner implements the standard
algorithm set on aws-lc-rs in
crypto.rs, 2,093 lines in
1.4.0. A parity script compared 54 operations against Node and all 54 matched. Reaching parity took extra Rust code.
aws-lc-rs can't parse X25519 PKCS #8 keys, so crypto.rs parses them itself. serde_json needed preserve_order
so JSON keys come out in Node's order. The code review on #266 found that AES-CTR ignored its length parameter,
which a test against Node covers.
The first use of aws-lc's random number generator costs 30 to 50 ms, which made the first omd_privacy callback
slow. Mod-visible randomness now reads the OS generator:
let mut bytes = [0u8; 16];
if getrandom::fill(&mut bytes).is_err() {
return;
}
That cut the privacy mod's first callback from 59.5 ms to 21.2 ms in the release benchmark.
The prototype passed --no-compilation-cache, copied from the Node runner's flags. Without it, a reused runner keeps
compiled code between callbacks. In the release benchmark the reused callback p50 went from 1.76 ms to 1.27 ms and
reused footprint from 27.9 MB to 18.8 MB. I attribute both to that flag based on the build timeline, but I didn't isolate it.
V8 crashed in two places I didn't expect. Tearing down an isolate with live globals segfaults, so main ends with
std::process::exit(0) instead of returning. And V8's single-threaded platform needs --single-threaded, or garbage
collector worker tasks crash with SIGSEGV.
The runner has no event loop. After each callback, settle runs the microtask queue once and treats a promise that
is still pending as a failure. Atomics.waitAsync returns such a promise, and it could never settle, so
bootstrap.js deletes it with delete Atomics.waitAsync;. Mods wait with Atomics.wait or the host's time.sleep.
On macOS, exporting V8's weak C++ symbols made dyld bind them on every runner start.
build.rs links the binary with
-exported_symbol,__mh_execute_header, so it exports only that symbol.
Results
The release runner went through the same harness, with Node 24.18.0 run alongside it (medians of 3 interleaved rounds, load 2.6 to 4.5):
| Node 24 | omd-runner |
|
|---|---|---|
| Footprint idle | 41.1 MB | 6.2 MB |
| Footprint after statusline / privacy | 52.0 / 47.9 MB | 9.1 / 9.3 MB |
| Footprint reused | 63.0 MB | 18.8 MB |
| Spawn to ready | 48.5 ms | 26.4 ms |
Fresh statusline / privacy / /mods p50 |
40.5 / 47.0 / 34.4 ms | 18.1 / 21.2 / 25.7 ms |
| Reused callback p50 | 1.50 ms | 1.27 ms |
| Serve render p50 | 0.24 ms | 0.26 ms |
Spawn to ready is 1.84× faster, and fresh callbacks are 1.3× to 2.2× faster. Steady-state timings didn't change: reused callbacks are within a quarter of a millisecond, and Node's serve render is 0.02 ms ahead, which I read as a tie.
Those are microbenchmark numbers for one runner at a time. The end-to-end check was a scripted terminal session with
a mock model, the default mod set, a debug codex and the release runner. It ran 9 runners at 6.4 to 14.8 MiB each,
against about 43 MiB each for the same set under Node in rc.2. The 1.0.1 changelog's "about 6–15 MB instead of about
43 MB" comes from that session, and its "about twice as fast" is the 1.84× spawn figure rounded up.
In the final security comparison, 14 of 17 probes produced identical results. Heap exhaustion exits with code 134 instead of a SIGABRT, with
the same message. The enum error adds the file name. The host enforces the input limit before the runner sees the
request.
Costs
The prototype's stripped omd-runner was 49.7 MB (V8, ICU data and swc). The release build was about 70 MB
unstripped and 52 MB stripped. In the 1.0 release candidates, no shipped OMD binary linked V8 except
codex-code-mode-host, so I made the runner its own artifact instead of linking V8 into both codex and omd-mod.
The pull request changed 66 files, +9,805 and −2,919 lines. It removed Node from the runtime: the store's node
field, set-node, the installer's Node check and the test harness's Node variable.
The mod environment is smaller than Node's. There is no TextDecoder, Buffer or structuredClone. Web Crypto
caps generated HMAC keys and derived bits at 8,388,608 bits and PBKDF2 at 4,294,967,295 iterations, where Node
accepts more. The
host model reference
lists them.
I broke updates on purpose. A 1.0.0 store recorded a node path, and the 1.0.1 store format rejects unknown fields,
so omd update from 1.0.0 failed and rolled back. I had written an installer migration and deleted it, because 1.0.0
had no users I knew of. 1.1.1 fixed the update from
1.0.0 (#310).
The omd-mod-author tooling, the account mods and omd_preview still use Node, but mods themselves never run in it.
The runner added a build dependency on a V8 prebuilt. build.py v8 downloads it from Codex's releases and checks
its SHA-256. The 1.0.1 release run failed once on an HTTP 502 from that download.
#271 added retries and a verified cache.
Measurement gaps
The raw benchmark files are gone, and only the summary tables remain. They have medians without p95 or variance, and
I didn't record the chip or macOS version for these runs. Nobody repeated them on another machine. The release-profile
end-to-end run (release codex with release omd-runner) wasn't part of the checks before merge. I have no
whole-product memory or latency comparison between 1.0.0 and 1.0.1 under the same conditions. The closest later
figure, from release builds on Codex 0.165 and 0.166 on an M5 Mac, is about 100 MB for OMD+ as installed, with
each omd-runner at 7 to 10 MB, and it has no Node baseline. I didn't build a V8 startup snapshot.
Safeguards in 1.4.0
The host enforces two memory limits. One is the 64 MiB V8 old space, and the other is a watchdog that kills a runner
past 512 MiB of physical footprint. Both fail the callback with mod_out_of_memory.
Under the optional root-owned broker, the host runs mods only with the omd-runner whose path and digest the broker
granted, and a missing runner fails with runner_unavailable instead of falling back to another program.
The runner tests compare AES-CTR against Node and fail if Atomics.waitAsync comes back.
At Codex 0.166.0, codex-cli links V8 152.2.0 too, which weakens one reason for keeping the runner a separate
artifact. I haven't measured what linking the runner into codex would save in disk size on that version, or what it would
cost in startup.