stdio vs HTTP: what an MCP transport actually costs
Both transports measured on the same machine with the same server, 400 calls per payload size. The gap is real, and it is roughly a tenth of a millisecond.
The numbers on this page came out of a run you can repeat. Last run . Harness: scripts/bench-mcp-transport.mjs
- Time
- 6 min
- Run against
- Node 22.23.2 · macOS darwin/arm64 · Apple M4 · MCP protocol 2025-06-18
People pick stdio because it is “faster”. This measures how much faster, on the assumption that a number settles it better than an argument does.
The short version: at the size of call an MCP client actually makes, stdio wins by about 0.1 ms. If your session makes two hundred tool calls, choosing the slower transport costs you under 30 ms in total. That is not a performance decision. It is a deployment decision that people have been defending with a performance argument.
What was measured
Two server processes, spawned cold, running byte-identical handler code. One speaks newline-delimited JSON-RPC over stdin and stdout. The other speaks JSON-RPC over HTTP POST on loopback. Both are separate processes from the client, because serving HTTP from inside the benchmark would have put client and server on one event loop and made every number a fact about the harness.
The server does nothing. tools/call returns a pre-generated string of the
requested size, so what is left in the measurement is framing, syscalls and
scheduling — the transport, and only the transport.
node scripts/bench-mcp-transport.mjs --runs 400 --warmup 50 --cold-starts 25
Three transports, because “HTTP” is two different things depending on whether the client reuses its connection, and the difference turns out to be larger than the difference people argue about.
Cold start
Twenty-five spawns each, killed between every one. A single cold start on this machine varies between 31 ms and 44 ms depending on what the OS is doing with its page cache, so this is a distribution rather than an observation.
| Transport | Boot p50 | initialize p50 | Ready p50 | Ready p95 |
|---|---|---|---|---|
| stdio | 0.38 ms | 30.88 ms | 31.26 ms | 40.16 ms |
| HTTP, keep-alive | 33.41 ms | 3.13 ms | 36.64 ms | 64.98 ms |
| HTTP, no keep-alive | 33.80 ms | 2.95 ms | 36.75 ms | 62.98 ms |
The split between the first two columns is the interesting part, and it is not a
performance story. HTTP can tell you when it is listening, so its wait is spent
in boot. stdio has no readiness signal at all — you write to a pipe and hope —
so a stdio server’s process start lands inside its first call. Same total,
different place, and the second one is why a slow stdio server looks to a client
like a hung request rather than a server that has not started yet.
Either way it is one node process booting, once per session, and the two are within 5 ms of each other.
Round-trip, once the pipe is warm
400 calls per size, 50 warm-up calls discarded.
| Transport | 1 KB p50 | 16 KB p50 | 256 KB p50 | 1 KB p99 |
|---|---|---|---|---|
| stdio | 0.020 ms | 0.070 ms | 0.907 ms | 0.121 ms |
| HTTP, keep-alive | 0.117 ms | 0.125 ms | 0.984 ms | 0.395 ms |
| HTTP, no keep-alive | 0.157 ms | 0.162 ms | 1.403 ms | 0.549 ms |
At 1 KB stdio is about six times faster. Six times faster than a tenth of a millisecond is still a tenth of a millisecond.
The ratio also collapses as the payload grows. At 256 KB the two are 8% apart, because by then both are doing the same thing — moving a quarter megabyte through the kernel — and the per-message framing that separates them has stopped mattering. Which means the multiple is largest exactly where the absolute numbers are smallest, and smallest where a tool response is big enough that you might have cared.
Eight calls in flight
Wall-clock for the whole batch, 50 batches.
| Transport | p50 | p95 |
|---|---|---|
| stdio | 0.051 ms | 0.151 ms |
| HTTP, keep-alive | 0.355 ms | 1.185 ms |
| HTTP, no keep-alive | 1.170 ms | 2.218 ms |
This one went the opposite way from the prediction. A single pipe should be the thing that serialises, and HTTP with eight sockets should be the thing that parallelises. Instead stdio batched eight requests into far fewer syscalls and came out seven times ahead, while HTTP paid for eight sockets’ worth of bookkeeping.
Worth being careful about what this does and does not show: it is a statement about syscall batching on loopback, not about a server that has real work to do. A server that blocks for 200 ms per call would reverse it immediately, because then the question is whether the server can overlap the work, and that is a property of the server rather than of the transport.
Keep-alive is the setting that actually costs you
Nobody argues about keep-alive, and it is worth more than the argument people do have: 0.157 ms against 0.117 ms at 1 KB, 1.403 ms against 0.984 ms at 256 KB, and 1.170 ms against 0.355 ms with eight calls in flight. If you are running an HTTP MCP server and have not checked that your client reuses connections, that is the whole gap between the transports, handed back for free.
What this does not tell you
- Loopback only. There is no network in any of these numbers. A remote HTTP server adds real round-trip time, and that is the case where transport choice genuinely changes the answer — it is also the one a local benchmark cannot produce.
- No TLS. HTTPS adds a handshake to every cold connection.
- The server does nothing. Any real server’s own work is added to both columns equally, which is precisely why it was left out, and also why these numbers are a floor rather than a prediction.
- One machine. Apple M4, macOS, Node 22.23.2. The harness is in the repo; the numbers on your hardware are one command away.
So how should you pick
Not on latency. On these:
- stdio when the server is local, holds credentials that should stay on the filesystem, and belongs to exactly one client. Its lifecycle is the client’s lifecycle, which is simple until you want two clients.
- HTTP when the server is remote, shared between clients, needs to be restarted or deployed on its own schedule, or needs to survive the editor being closed.
The reason the latency argument persists is that it is easy to make and hard to check. It is 0.1 ms.
Raw results: mcp-transport-2026-08.json.