MCPMeasured

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.

TransportBoot p50initialize p50Ready p50Ready p95
stdio0.38 ms30.88 ms31.26 ms40.16 ms
HTTP, keep-alive33.41 ms3.13 ms36.64 ms64.98 ms
HTTP, no keep-alive33.80 ms2.95 ms36.75 ms62.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.

Transport1 KB p5016 KB p50256 KB p501 KB p99
stdio0.020 ms0.070 ms0.907 ms0.121 ms
HTTP, keep-alive0.117 ms0.125 ms0.984 ms0.395 ms
HTTP, no keep-alive0.157 ms0.162 ms1.403 ms0.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.

Transportp50p95
stdio0.051 ms0.151 ms
HTTP, keep-alive0.355 ms1.185 ms
HTTP, no keep-alive1.170 ms2.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.

← All mcp