Oam.js: a runtime for TypeScript and MCP servers, with the denominators attached
Oam.js (oam) is a JavaScript/TypeScript runtime written in Rust on V8. It runs TypeScript directly, including .tsx/.jsx, with no build step and no loader hook, and it is built to host MCP servers — many short-lived processes, idle most of the time, each needing to answer its first request fast.
It is beta. That word is doing real work here, and the specifics are near the end.
The thing worth arguing about is not speed. It is that every number below comes with the denominator it was computed against, and with the cases where another runtime wins. A percentage without a denominator is a marketing asset, not a measurement.
Conformance: 429/431, and what that ratio is over
A vendored subset of Node's own test suite runs on every release. The current scorecard (CONFORMANCE-NODE.md, generated at commit 869aa9e, oam 0.9.0 against Node v22.22.2):
- 429/431 (99.5%) on windows-aarch64
- 438/440 (99.5%) on macos-aarch64
- 439/441 (99.5%) on linux-x86_64
Now the qualifications, from that scorecard and docs/node-divergences.md:
That is pass over tests that RAN, not pass over tests that exist. Against every vendored file on Windows the same run is 429/476 (90.1%) — 429 pass, 2 fail, 22 skip at runtime, 23 unrunnable by the harness. Most of the unrunnable set needs a node:internal/* module oam does not have, a large share of those being internal/test/binding, Node's C++ test hooks. Inventing those modules to make the ratio look better would be fabrication.
The corpus is a subset of Node's suite. It covers assert, buffer, events, path, process, querystring, stream, string_decoder, timers, url, and util. The socket- and fixture-heavy modules — fs, net, http, child_process, tls — land in later tranches, and the percentage will move when they do.
Some skips are not oam gaps. Real Node, run against the same vendored tree on the same host, skips several of them identically (POSIX-only tests on Windows, styletext with no TTY). Windows also runs the fewest tests of the three platforms, so its ratio alone is not the picture — the POSIX hosts carry larger denominators because they run the tests Windows skips.
The oracle is exit 0. Node core tests self-assert, so a test "passes" when it runs to completion without throwing. That is Node's own bar, but it is coarser than a golden-output diff. The stricter check is the Node differential suite in CONFORMANCE.md: 55/55 cases with byte-identical stdout against a live node v22.22.2. WPT URL data is 888/888 on the constructor suite and 278/278 on setters.
Both remaining failures on every platform are deliberate, and one is permanent. test-process-versions.js fails because process.versions refuses to publish version strings for libraries oam does not contain — no uv, no openssl, no llhttp. It publishes oam's real dependency tree under real names (tokio, hyper, reqwest, rustls, ring, oxc, and the rest), read from Cargo.lock at build time so it cannot drift from what is linked. The sharp case is modules/napi: addon loaders read those to decide a precompiled .node binary is loadable. oam cannot load one, so the polite lie would turn a package's working JS fallback into a crash.
Benchmarks: Bun wins three of the nine cases, Node one
The committed table in BENCHMARKS.md is generated by cargo run -p xtask -- bench --release --compare at commit acec008, release profile, host windows-aarch64, and its header records the runtimes as oam 0.9.0, node v22.22.2, bun 1.3.14.
These numbers replace an earlier table that was wrong twice over. It was measured on a build old enough to still report oam 0.0.1, and its mcp-idle-rss column was produced by a harness bug: the Windows parser split tasklist output on ,, which cut the memory field at its own thousands separator, and rejoined the halves backwards. It read 40.65MB where the real figure is 25.23MB — understating oam's own advantage. One machine's numbers are still one machine's numbers, so re-run them on yours.
All times in milliseconds, lower is better. mcp-idle-rss is MB.
| Case | oam | node | bun |
|---|---|---|---|
| cold-start | 59.58 | 113.42 | 307.97 |
| url-parse | 6.53 | 9.30 | 5.43 |
| http-throughput | 47.78 | 107.75 | 33.98 |
| fs-read | 30.05 | 28.91 | 38.73 |
| json-parse | 74.50 | 115.99 | 91.15 |
| crypto-hash | 223.89 | 292.29 | 38.99 |
| mcp-cold-start | 35.00 | 250.87 | 483.19 |
| mcp-idle-rss | 25.23 | 61.34 | 98.43 |
| mcp-first-call-latency | 0.34 | 2.40 | 5.04 |
Bun is faster than oam at url-parse, http-throughput and crypto-hash on the same hardware. On crypto-hash it is not close: 38.99ms against oam's 223.89ms. If raw single-process throughput is what you are optimising, benchmark Bun and you will probably pick Bun. Node edges oam on fs-read (28.91ms against 30.05ms) — close enough that it is a tie in practice, and the honest reading is that oam has no advantage there.
oam's case is the other shape. mcp-cold-start is 35.00ms against Bun's 483.19ms and Node's 250.87ms; mcp-idle-rss 25.23MB against 98.43MB and 61.34MB; mcp-first-call-latency 0.34ms against 5.04ms and 2.40ms. A broker that starts a dozen sidecars pays each of those costs a dozen times, and then holds the RSS for as long as the sidecars live.
One methodology caveat: in mcp-cold-start oam uses its built-in oam:mcp virtual module while Node and Bun load @modelcontextprotocol/sdk (same noop tool, same stdio transport). That measures what it costs to start an MCP server on each runtime, not the runtimes in isolation — part of oam's win is that the protocol surface ships in the binary instead of being installed from npm.
What will bite you
- Beta. Breaking changes before 1.0 are still possible and are called out in the changelog — 0.9.0 changed
child_processbehavior you may have depended on. There is no LTS yet, so this is not the runtime for a service you are on-call for. - Binaries are unsigned. They are checksummed against a published
SHA256SUMSand the installer verifies that, but there is no code signature. - Native addons are alpha and off by default, behind
OAM_ENABLE_NATIVE_ADDONS=1. An addon compiled againstnode.execan deadlock the OS loader inside a different host, before any oam code runs — unkillable and undiagnosable. A clean throw instead lets the standardtry { require(native) } catch { js fallback }pattern work. If your dependency tree has a mandatory.nodebinary with no JS fallback, use Node. - No linux-arm64 release. Five targets ship: windows-x64, windows-arm64, macos-x64, macos-arm64, linux-x64. linux-arm64 builds from the same tree but the V8 snapshot forbids cross-compiling, so it needs a native ARM Linux builder. The installer refuses that target by name rather than guessing.
- A typeless
.jsfile is ESM in oam and CommonJS in Node. The divergence most likely to hit you first:requireis not defined in such a file. Files insidenode_moduleskeep Node's CommonJS default. Fix with"type": "commonjs", a.cjsrename, orimport. require()of an ES module throwsERR_REQUIRE_ESM. Node 22 supports it; oam does not yet. Useawait import().oam installskips lifecycle scripts, so a package that compiles an addon inpostinstallinstalls but does not work.- Narrowed surfaces are documented, not hidden:
TextDecoderis utf-8 only,crypto.createHashcovers md5/sha1/sha224/sha256/sha384/sha512,os.constants.errnois a POSIX table on every platform (compareerr.codestrings, not numbers),process.permissionis undefined. The full list sits indocs/node-divergences.md, alongside a section of suspicions explicitly labelled unverified.
When to pick something else
Node, if you need native addons, the full ecosystem surface, or you are operating a long-lived production service today. Deno, if you want its standard library, deno deploy, or its module ecosystem — those are not goals here. Bun, if single-process throughput is the axis you are optimising, or you want the bundler, test runner and package manager in the same binary.
oam is for running TypeScript and MCP servers. If that is the workload, the argument is the table above and the denominators under it. If it is not, one of the other three is the better tool, and docs/why-oam.md says which.
License is Apache-2.0, forever. The scorecards and the benchmark table are generated by the CI gate and never hand-edited, which is the only reason it is safe to quote them here.