MCP servers as sidecars: what a runtime owes a fleet of small processes
oam is a JavaScript/TypeScript runtime written in Rust on V8, 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. That is a description of a workload, and the workload decides which benchmark numbers matter. This post is about the three rows that describe it, the methodology caveat attached to them, and the rows where Bun wins instead.
Why a fleet multiplies every cost
An MCP server usually runs as a sidecar: a broker spawns one process per server, speaks to it over stdio, sends initialize, and then mostly waits. That shape changes the arithmetic. A single long-lived web service pays its cold start once and amortizes it across hours of traffic, so per-process overhead rounds to zero. A broker that starts a dozen sidecars pays the cold-start cost a dozen times, and then holds the idle memory of all twelve for as long as the sidecars live. Nothing amortizes, because the processes are idle most of the time — there is no stream of requests to spread the fixed costs over.
So the numbers a sidecar fleet actually feels are: how long until a spawned server answers initialize, how much memory it holds while waiting, and how fast the first tools/call comes back when a request finally arrives. Throughput barely enters into it.
The three rows
The committed table in BENCHMARKS.md is generated by cargo run -p xtask -- bench --release --compare at commit acec008, release profile, host windows-aarch64. The recorded runtimes are oam 0.9.0, node v22.22.2 and bun 1.3.14. These are one machine's numbers and not a leaderboard — the harness is in the repo, and the right move is to re-run it on yours.
| Case | oam | node | bun | vs node |
|---|---|---|---|---|
| mcp-cold-start (ms) | 35.00 | 250.87 | 483.19 | 0.14x |
| mcp-idle-rss (MB) | 25.23 | 61.34 | 98.43 | 0.41x |
| mcp-first-call-latency (ms) | 0.34 | 2.40 | 5.04 | 0.14x |
What each row measures. mcp-cold-start is wall-clock from process spawn to the first MCP initialize response over stdio, timed from outside because a cold start cannot time itself. mcp-idle-rss is resident set size after initialize completes and the server sits waiting for the next request — the runtime's memory overhead for hosting a server that is doing nothing, which is what a sidecar does most of the time. mcp-first-call-latency is wall-clock from sending tools/call to receiving the response on an already-initialized server: warm-path dispatch, with no cold-start cost included.
Multiply by the fleet. Twelve idle sidecars at Node's 61.34MB hold about 736MB; at Bun's 98.43MB, about 1.18GB; at oam's 25.23MB, about 303MB. The cold-start cost recurs the same way every time the broker brings the fleet up or restarts a server.
The caveat that matters
Methodology: 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.
For the runtimes without the protocol stack, read the plain cold-start row from the same run — wall-clock from spawn to exit of a console.log: oam 59.58ms, Node 113.42ms, Bun 307.97ms. Same ordering, and a much smaller gap between oam and Node. The two rows measure different things (spawn-to-exit against spawn-to-first-response), so read them side by side rather than subtracting one from the other.
Where Bun wins
On the same hardware, in the same run, Bun beats oam at url-parse (4.83ms against oam's 6.34ms), http-throughput (31.12ms against 55.17ms) and crypto-hash (26.91ms against 150.67ms). On crypto-hash it is not close. If raw single-process throughput is what you are optimising — one long-lived server, hot loops, sustained load — benchmark Bun, and you will probably pick Bun. The sidecar rows do not contradict the throughput rows; they answer different questions, and your workload is only asking one of them.
Measurement notes, and the beta word
The harness times each case in-process, except the two cold-start cases, which are wall-clock from process spawn and can only be measured from outside. A runtime that is not on PATH is skipped, never estimated. The oam binary is copied out of target/ and exec'd once before timing starts, so a concurrent build cannot replace it mid-run and the one binary under test does not pay a cold-cache penalty its warm, installed competitors avoid. The profile and host are recorded next to the table, because a debug build against release Node is not a comparison. The machine-readable twin lives in bench/results.json.
oam is beta. Breaking changes before 1.0 are still possible and are called out in the changelog, and there is no LTS yet — this is not the runtime for a service you are on-call for. But if the workload is the one at the top of this post — a fleet of small MCP servers, spawned often, idle mostly, each answering its first request fast — the argument is the three rows above, with the caveat attached. If it is a different workload, docs/why-oam.md says which of Node, Deno or Bun to pick instead.