Operations
Operations25 min

Benchmarks

Throughput, latency, CPU, memory and threads of the current build, measured — tool calls on one gateway and on NATS and Redis fleets, sessions, notifications, watches, limits, MCP Events, the coordinator store and the plugin boundary — and how to size a deployment from them.

This page gives what the gateway costs, measured on the current build: how many tool calls one gateway serves and at what latency, what a fleet adds, what sessions, notifications, watches, limits and MCP Events cost, what the coordinator store and the plugin boundary cost, and how memory and threads follow the load. The commands that reproduce every table are at the end of the page.

At a glance

one gateway alone, 2 coresfour gateways on NATS R3, 2 cores each
Tool calls a second, default settings4 801 stateless, 3 811 legacy9 773 stateless, 2 745 legacy
Tool calls a second, tuned settings7 718 stateless, 7 727 legacy14 772 stateless, 14 872 legacy ¹
Gateway CPU per tool call251–432 µs350–952 µs
Notifications, p50 / p994 / 7 ms17 / 51 ms
MCP Events into 64 push streams ²about 1 600 events/s in, up to 107 000 deliveries/s outabout 400 events/s in with every delivery within 3 s, up to 865 in with none refused; up to 50 300 deliveries/s out
Memory per gateway right after a run, 64 connections586 MiB default, 104 MiB tuned385 MiB default, 197 MiB tuned
A replica killed—its watches moved in 15.7 s, no change lost

¹ The load generator's limit: each gateway used 1.3 of its 2 cores. ² On the isolated host, in the same layout (see How the figures were measured).

  • One gateway on two cores serves about 4 800 tool calls a second with the default settings and about 7 700 with three settings that are off by default. Its own work is 250–430 µs of CPU a call; the rest of the latency is the queue of 64 connections.
  • A fleet adds stateless capacity gateway by gateway. A stateless (2026-07-28) call needs nothing from the coordinator. A legacy (2025-11-25) call with the default settings writes its session to the coordinator: three NATS nodes on 2 cores carry about 2 750 of them a second, one Redis server about 7 800. unary_json_fast_path takes that write away.
  • MCP Events reach every stream once. The inbound route sets the rate a deployment takes: about 1 600 events a second on one gateway alone, all 1 000 offered on four gateways over Redis, and about 400 with every delivery within 3 s on NATS R3 with 2 cores, where the coordinator is the limit: past it the gateways refuse what the inbox cannot hold and the streams fall behind.
  • Limits and watches hold on a fleet: every fleet-wide concurrency cap, rate limit and budget held, on NATS and on Redis (see Limits). No watched change was lost when a replica was killed.
  • Memory follows the load. A burst's threads exit about a second after it; one gateway on NATS R3 is back at 136–186 MiB within seconds (see Memory and threads).

How the figures were measured

Hostone VM: AMD EPYC 7502P, 16 vCPU, 62 GiB RAM, virtual SSD (fsync p50 0.9 ms, p99 3.3 ms), Linux 6.1
Isolated hostfor MCP Events and A larger machine: a VM on the same CPU type, 32 vCPU pinned to 16 physical cores and their second hyper-threads, 24 GiB RAM, NVMe, Debian 12, steal 0–3.5 %; the small shape there is the layout of this table (each gateway on 2 cores, the coordinator on 2 shared cores, the load generator on 4)
Gatewayan optimized build (release images add fat LTO and one codegen unit); each gateway on 2 cores of its own
Coordinatornats-server (2.14.8 for MCP Events, 2.10.24 for the other figures), three nodes as one JetStream cluster (replicas 3, file storage) on 2 shared cores; redis-server 7.0.15 without persistence on the same 2 cores; or none (cluster.kind: single_node)
Loadmcp-loadgen (tools/bench/proxy-load) on 4 cores: 64 connections, each sends its next request when the last one is answered
Toola mock backend plugin (cdylib) that answers at once; every call is audited to the built-in local file sink, as by default
Runs30 s after a 3 s warm-up; tool calls three runs per figure, the median; the other scenarios one run, or the range of the runs where a range is shown

Everything runs on loopback on the one host, so the gateways, the coordinator and the load generator share its 16 cores (on the isolated host, its 32 vCPU). Two measures carry over to other hosts:

  • CPU per call — the gateway's user and system CPU time over a run, divided by the calls it served. One core serves about 1 000 000 / (CPU µs per call) calls a second.
  • Coordinator messages per call — what the NATS nodes received per call, replication included. It sets the coordinator's share of the work.

The VM shares its physical host with other guests. When the hypervisor gives part of the CPU time to them (steal), every figure falls: one gateway alone with the tuned settings served 7 718 stateless calls a second at 0 % steal and 2 817 at 25 %. Every tool-call, limit and MCP Events result records the steal of its run; those on this page ran at 0–5 %, except where a note says otherwise.

Most figures of Inside one process come from the nightly benchmark workflow on the CI runners, a different host; that section names which.

The path of a tool call

A stateless (2026-07-28) call sends the coordinator nothing unless a fleet-wide limit applies to the tool. A legacy (2025-11-25) call reads its session and, when it answers with an SSE stream, writes the session row with the stream's replay: that is the coordinator traffic of the legacy rows below. Each call waits for its two audit events (mcpg.tool.call.allowed before the backend, mcpg.tool.call.completed after it) to be durable. The local file sink commits every batch of queued events with one write and one fsync, so a call waits for about two fsyncs, whatever the number of calls in flight.

Tool calls on one gateway

SCENARIOS=scale: tools/call of a mock tool from 64 connections, default settings. Memory is the gateway's resident set right after a run, which is its working set at full speed (see Memory and threads).

fleetwirecalls/sp50 / p99gateway CPU per callNATS messages per callmemory
one gateway alonestateless (2026-07-28)4 80113.0 / 20.9 ms338 µs—586 MiB
one gateway alonelegacy (2025-11-25)3 81116.3 / 26.7 ms432 µs—637 MiB
one gateway, NATS R3stateless4 69513.3 / 22.1 ms338 µs0.04764 MiB
one gateway, NATS R3legacy2 15328.8 / 53.3 ms718 µs7.6801 MiB

The same load with the three settings of Settings that change throughput on:

fleetwirecalls/sp50 / p99gateway CPU per callNATS messages per callmemory
one gateway alonestateless7 7187.9 / 12.1 ms251 µs—104 MiB
one gateway alonelegacy7 7278.2 / 12.0 ms258 µs—105 MiB
one gateway, NATS R3stateless7 7918.1 / 12.4 ms256 µs0.02374 MiB
one gateway, NATS R3legacy7 5378.4 / 13.0 ms265 µs0.02501 MiB
  • A stateless call costs the same alone and in a fleet: it reads and writes nothing in the coordinator (the 0.02–0.04 messages per call are the fleet's own leases and heartbeats, spread over the calls).
  • A legacy call with the default settings is dearer in a fleet: it answers with a one-frame SSE stream and writes the session row with that stream's replay, about 8 NATS messages per call with replication. Alone, the same write goes to the gateway's memory.
  • With the tuned settings both wires cost the same, alone or in a fleet: the gateway uses its 2 cores, so CPU sets the rate.
  • Latency is queueing. 64 connections each wait for their answer before the next call, so the latency is about 64 / (calls/s): a gateway's own work per call is the CPU column.

Settings that change throughput

Three settings are off by default because each one gives something up. The "tuned" figures have all three on.

SettingWhat it skipsWhat it gives upUse it when
gateway.server.unary_json_fast_path: trueOn the legacy wire, a unary call that sends no notifications is answered with one application/json body, not a one-frame SSE stream: no stream bookkeeping and no session write per call (the session row is still refreshed once per refresh interval)The answer cannot be resumed with Last-Event-ID; it never needs to be, as it arrives whole. Streamable HTTP lets the server pick either form, and a client must accept bothYour clients use the 2025-11-25 wire, above all on a fleet
gateway.server.access_log: falseThe two structured log events (request received, request completed) of every requestThe per-request access log; audit events, warnings, errors, metrics and traces stayAn ingress or a sidecar logs the requests already
plugins[].inline_dispatch: trueThe spawn_blocking hand-off and the per-call timeout of the plugin's slot calls: the call runs on the caller's threadA slow or hung plugin holds a runtime worker, with no timeout to stop itTrusted, in-process, fast plugins only

What the three do together, one gateway on NATS R3:

wiresettingscalls/sp50 / p99CPU per callcoordinator coresNATS messages per call
statelessdefault4 69513.3 / 22.1 ms338 µs0.020.04
statelesstuned7 7918.1 / 12.4 ms256 µs0.010.02
legacydefault2 15328.8 / 53.3 ms718 µs1.267.6
legacytuned7 5378.4 / 13.0 ms265 µs0.020.02

unary_json_fast_path alone accounts for the legacy wire's coordinator traffic: it is what lets legacy calls scale with the fleet (see below). inline_dispatch takes the last thread hand-off out of a call: a gateway then needs about 10 threads instead of about 100 at the same concurrency.

A fleet on NATS

SCENARIOS=scale on one, two and four gateways over the three NATS nodes, three runs of each load, medians. Each gateway has 2 cores of its own; the coordinator has 2 for its three nodes.

gatewayssettingswirecalls/sp50 / p99CPU per callcores per gateway (of 2)coordinator cores (of 2)NATS messages per call
1defaultstateless4 69513.3 / 22.1 ms338 µs1.580.020.04
2defaultstateless7 7988.0 / 12.6 ms377 µs1.470.020.03
4defaultstateless9 7736.3 / 13.4 ms441 µs1.070.020.02
1defaultlegacy2 15328.8 / 53.3 ms718 µs1.551.267.6
2defaultlegacy2 75621.6 / 50.5 ms855 µs1.141.615.8
4defaultlegacy ²2 74522.3 / 52.3 ms952 µs0.651.676.2
1tunedstateless7 7918.1 / 12.4 ms256 µs2.000.010.02
2tunedstateless14 1464.4 / 7.4 ms272 µs1.940.020.01
4tunedstateless14 7724.2 / 6.6 ms354 µs1.310.020.01
1tunedlegacy7 5378.4 / 13.0 ms265 µs2.000.020.02
2tunedlegacy13 7724.5 / 7.3 ms280 µs1.930.020.02
4tunedlegacy14 8724.2 / 6.7 ms350 µs1.290.030.02

² Each call on the next replica, so every call reads its session from the coordinator; the runs with each connection's calls on one replica had 13 % steal (2 116 calls/s). The other legacy rows keep each connection's calls on one replica; with each call on the next replica, two and four tuned gateways served 11 949 and 14 114 calls a second.

The lines, from the top at four gateways: legacy tuned (green), stateless default (blue), legacy default (orange).

  • Stateless calls and tuned legacy calls scale with the gateways until the load generator (4 cores) sets the limit: at four gateways each gateway uses 1.3 of its 2 cores. The coordinator does almost nothing for them.
  • Legacy calls with the default settings do not scale past two gateways: the coordinator is the limit. At four gateways the three NATS nodes use 1.67 of their 2 cores while each gateway uses 0.65. Each such call writes its session's replay, 6–8 NATS messages with replication; how many depends on whether the gateway's own NATS node leads the stream.
  • The tuned settings make the legacy wire as cheap as the stateless one, because a unary call answers with one JSON body and writes nothing to the coordinator.
  • CPU per call rises a little with the fleet (256 → 354 µs tuned) at the same load: each gateway serves fewer calls for the same fixed work. For sizing, use the four-gateway figures.

A fleet on Redis

COORDINATOR=redis: the same loads over one redis-server without persistence on the coordinator's 2 cores, one and four gateways, three runs of each load, medians. NATS R3 keeps three copies of every write; this Redis keeps one.

gatewayssettingswirecalls/sp50 / p99CPU per callcores per gateway (of 2)Redis cores (of 2)
1defaultstateless4 96812.6 / 19.6 ms335 µs1.660.00
1defaultlegacy2 76522.9 / 37.1 ms681 µs1.880.15
4defaultstateless10 0906.2 / 8.7 ms432 µs1.100.00
4defaultlegacy7 7968.0 / 12.5 ms800 µs1.560.49
4defaultlegacy, each call on the next replica6 1019.0 / 24.4 ms835 µs1.270.38
1tunedstateless8 3887.5 / 11.6 ms238 µs2.000.00
1tunedlegacy8 0377.8 / 12.1 ms248 µs2.000.00
4tunedstateless14 8164.2 / 6.4 ms354 µs1.310.00
4tunedlegacy14 8154.2 / 6.7 ms353 µs1.300.00
4tunedlegacy, each call on the next replica14 3564.3 / 8.1 ms359 µs1.270.00
  • On Redis the legacy wire scales with the gateways with the default settings too: the session writes that hold three NATS nodes to about 2 750 calls a second take one Redis server 0.49 of a core at 7 800. The gateways' CPU is the limit there (800 µs a legacy call).
  • A stateless call costs the same on either coordinator: the coordinator sees none of it.
  • With the tuned settings the two coordinators give the same fleet: about 14 800 calls a second on four gateways, the load generator's limit, with the coordinator idle.

Sessions and notifications

These belong to the legacy (2025-11-25) wire, whose sessions live in the coordinator on a fleet.

SCENARIOS=churn: 16 connections each open a session on one replica, open its stream on the next replica (with one gateway, the same one), and end the session on the first; the stream must end.

fleetsessions ended a secondstream end p50 / p99stuck streams
one gateway alone3 2910.6 / 1.5 ms0
one gateway, NATS R3311–3619.5–11.3 / 207–220 ms0
four gateways, NATS R336218 / 46 ms0
one gateway, Redis7704.8 / 8.3 ms0
four gateways, Redis1 3662.2 / 6.2 ms0

SCENARIOS=listen: 64 sessions on every replica subscribe to 20 resources, and a webhook changes them 20 times a second through the replicas in turn; every subscriber must hear every change once.

fleetnotificationsp50 / p99
one gateway alone38 464 of 38 4644 / 7 ms
one gateway, NATS R338 464 of 38 46422 / 49 ms
four gateways, NATS R338 464 of 38 46417 / 51 ms
four gateways, Redis38 464 of 38 46410 / 18 ms
  • A session costs coordinator round trips on a fleet: its creation, the read on the replica that serves its stream, and its end, which every replica hears. A gateway alone keeps all of it in memory.
  • A notification is kept for a stream that connects later: it is written to the session's buffer in the coordinator before it is sent, which is most of its latency on a fleet.

Watches and failover

SCENARIOS="watch failover": 32 sessions subscribe to 50 resources that the gateway polls every 10 s from an upstream whose versions move every 25 s.

fleetchanges toldtold twicemisseddelay p50 / p99
one gateway aloneevery one005.1 / 10.0 s
one gateway, NATS R3every one004.9 / 9.9 s
four gateways, NATS R3every one005.2 / 10.0 s
four gateways, one killed (SIGKILL) halfwayevery one005.3 / 28.4 s
four gateways, Redisevery one004.9 / 9.9 s
four gateways, Redis, one killed halfwayevery one005.5 / 36.9 s
  • A fleet polls each resource once: the replicas split the resources into shards, each held by one replica under a lease, so four gateways poll no more than one.
  • The delay is the poll interval: a change waits on average half of it.
  • After a kill, the survivors take the dead replica's shards when its leases expire: on NATS the last shard moved 15.7 s after the kill, on Redis 19.9 s, and no change was lost; the longest wait (33.4 s and 39.4 s) is that hand-over plus a poll interval.

Limits

A fleet-wide limit (scope: global) is decided in the coordinator: a concurrency cap of N is N slot keys that the replicas take and give back, a rate limit and a budget are counters every replica charges. Two things keep that cheap:

  • A replica never holds more than the cap at once, so it asks the coordinator only for the calls that hold one of its N local slots; the others are refused at once, without a store operation.
  • A refused rate limit is remembered on the replica until a token fits, and a refused budget for up to one second (never past its window), so a hammered limit costs the coordinator nothing until its answer can change.

SCENARIOS=governed: 64 connections call one capped tool as fast as they can for 30 s; a refused call is sent again at once.

limitcheckone gateway aloneone gateway, NATS R3four gateways, NATS R3 (2 runs)four gateways, Redis (3 runs)
8 calls in flight, each 100 msat most 80 calls/s74.9/s67.2/s69.9–70.3/s73.9–74.7/s
600 calls/min, burst 10at most 10 + 10/s × 30 s = 310310310310310
a budget of 1 000 callsat most 1 0001 0001 0001 0001 000
calls judged a second, refused included—10 400–11 8009 500–12 50019 200–27 40021 800–29 200
coordinator cores (of 2)——0.08–0.470.17–1.490.01–0.27
  • Every limit held on every fleet. A rate bucket is one counter that every replica moves: the time up to which its calls are paid for. A call raises it to the present when it lies in the past and adds one interval, in one atomic step of the coordinator, so two replicas never pay for the same interval.
  • Most refusals answer from the replica's memory: the rate limit judged 12 500 calls a second on one gateway on NATS with the coordinator at 0.08 cores.
  • The concurrency cap admits under its ceiling on a fleet. An admitted call holds a slot key in the coordinator for its 100 ms plus the round trips that take and free the key, and a replica that finds every key taken refuses for 20 ms: 67–70 calls a second of the 80 the cap allows, against 75 on a gateway alone.

MCP Events

The owner (one replica, under a lease) moves the inbox rows into the log in batches, and every replica reads each new row from the store once for all of its streams. The posts a replica holds at once share their slots: one read of the inbox's bounds and one counter step reserve a slot for each of them.

SCENARIOS=events on the isolated host, small shape: 64 push streams on events/stream (one principal), one event type fed by an inbound webhook source, one producer per 100 events a second offered, each posting its next event when the last one is answered. A post is refused when the answer is not 202. A delivery counts when its stream heard the event within 3 s after the last post. The gateways also load the Prometheus plugin (FLEET_METRICS=1), whose /metrics gives the coordinator calls behind each event.

fleetevents offered/saccepted/sdeliveries within 3 sp50 / p99gateway CPU per deliverycoordinator cores (of 2)NATS messages per event
one gateway alone100100192 256 of 192 2565 / 13 ms37 µs——
400400768 256 of 768 25610 / 42 ms35 µs——
1 0001 0001 920 640 of 1 920 64012 / 54 ms24 µs——
1 6001 5973 066 304 of 3 066 304110 / 1 233 ms17 µs——
3 2001 804, 41 772 refused93 %6 010 / 6 417 ms17 µs——
one gateway, NATS R3 ³100100all16–17 / 34–36 ms87–98 µs0.50–0.56104–108
400400all27–29 / 928–1 355 ms59–61 µs1.28–1.2982–84
1 000948–999, 54–1 556 refused75–77 %5 415–7 261 / 9 969–11 522 ms36–38 µs1.45–1.4652
1 600808–864, 22 104–23 768 refused69 %12 510–12 879 / 13 811–14 983 ms47–50 µs1.40–1.4254–55
3 200640–683, 71 759–75 552 refused61–63 %13 609–15 848 / 18 835–19 846 ms66–74 µs1.51–1.5676
four gateways, NATS R3 ³100100all18–19 / 33–39 ms193–207 µs0.83149–152
400398all31–32 / 197–236 ms101–106 µs1.66–1.67107–108
1 000829–865, none refused89–91 %2 174–3 375 / 6 590–6 684 ms64–69 µs1.81–1.8263
1 600752–849, 7 396–15 471 refused67–72 %8 011–8 174 / 13 670–16 964 ms81–89 µs1.87–1.8960–71
3 200567–628, 77 202–78 670 refused58–63 %19 351–19 853 / 20 543–22 143 ms129–150 µs1.86–1.8784–97
four gateways, Redis100100192 256 of 192 2566 / 10 ms117 µs0.06—
400400768 256 of 768 25612 / 83 ms92 µs0.19—
1 0001 0001 920 640 of 1 920 6407 / 120 ms71 µs0.39—

³ nats-server 2.14.8. Two runs, each rate after the one before it on the same fleet; a range where they differ, and a share of the deliveries where not all arrived within 3 s. Steal 0–1 %.

The bars (blue) are one gateway alone; the lines are one gateway (orange) and four gateways (green) on NATS R3, one run each (deliveries within 3 s a second, over the 30 s run).

  • Every accepted event reached every stream once in every run: no duplicate, and no event a stream did not expect.
  • The inbound route sets the rate. An accepted event is a row write before its 202, and the posts a replica holds at once share one counter step for their slots. One gateway alone took about 1 600 events a second into the file-backed inbox, at 1.8 of its 2 cores. Four gateways over one Redis server took all 1 000 offered with Redis at 0.39 of a core. On NATS R3 with 2 cores the coordinator is the limit: one gateway took all 400 a second offered and four gateways 398, every delivery within 3 s, with the three nodes at 1.3–1.7 of their cores.
  • Past that rate, the inbox fills and the gateways refuse. The owner moves the events into the log slower than they arrive; once the inbox holds inbox_max_rows (10 000 by default), a post gets 503 with a Retry-After at once, and the streams fall behind. At 1 000 offered four gateways took 829–865 a second and refused none (each post was answered later), and 89–91 % of the deliveries arrived within 3 s; at 1 600, 752–849 and 67–72 %. A producer that gets 503 or 429 should retry after the Retry-After it is given. Size the coordinator for the event rate, or use Redis; more coordinator cores raise the rate less than their count (see A larger machine).
  • Fan-out is cheap. A delivery costs 17–37 µs of gateway CPU on one gateway alone, 36–98 µs on one gateway on NATS, 64–207 µs over four gateways on NATS and 71–117 µs over four on Redis: the streams of a replica share each read of the log, so 64 streams cost about what a few do.
  • The coordinator's share is 52–108 NATS messages per event on one gateway and 60–152 on four, replication included: the inbox write, the owner's drain (dedup marker, row, head every 32 rows, inbox delete), and each replica's read of the new rows. It is lowest at 1 000–1 600 a second, where more posts share each counter step, and rises again past that with the refused posts.

Memory and threads

One gateway alone, a 30 s burst of stateless calls from 64 connections, sampled every 2 s, then 60 s without load:

settingscalls/sp50 / p99threads at idle → under loadmemory at idle → peakmemory 60 s later
default (4 runs)5 000–5 74410.8–12.5 / 17.0–19.9 ms6–8 → 97–10661–63 → 522–579 MiB136–543 MiB
tuned (inline_dispatch on the backend)8 1267.7 / 12.3 ms8 → 861 → 105 MiB82 MiB

SCENARIOS=memtime: three bursts of 30 s on one gateway, with 120, 60 and 120 s without load after them:

fleetsettingsburstcalls/smemory beforepeakafter the idle time
one gateway alonedefaultstateless5 14161 MiB567 MiB140 MiB
stateless again4 729140 MiB516 MiB140 MiB
legacy3 505133 MiB565 MiB158 MiB
tunedstateless7 74663 MiB105 MiB83 MiB
stateless again7 83983 MiB104 MiB80 MiB
legacy7 76780 MiB104 MiB83 MiB
one gateway, NATS R3defaultstateless4 71274 MiB547 MiB137 MiB
stateless again4 834137 MiB518 MiB141 MiB
legacy2 309149 MiB689 MiB186 MiB
tunedstateless7 21775 MiB112 MiB80 MiB
stateless again7 65280 MiB113 MiB87 MiB
legacy8 25591 MiB161 MiB94 MiB

Blue and orange are two runs with the default settings, green is inline_dispatch on the backend.

  • Memory follows the work in flight, not the history. With the default settings, every plugin call in flight holds a blocking-pool thread (the spawn_blocking hand-off that bounds a plugin's time) and its allocator cache: about 100 threads and 500–580 MiB at 64 connections.
  • It comes back. The threads exit about a second after the burst. Their memory goes back to the system once other allocations of the gateway reclaim the pages they left: on a gateway alone that idles, 4 s to more than a minute after the burst (the two default runs above; 27–31 s in the memtime runs), on one gateway on NATS within seconds.
  • Inline plugins keep a gateway small: a call runs on the worker that serves its request, so the same burst needs 8 threads and 105 MiB, at 41–63 % more calls.
  • On a fleet, a gateway gives a burst back within seconds. The coordinator's store calls run on the same blocking pool and keep a few of its threads busy between bursts: one gateway on NATS went back to 7–9 threads and 136–186 MiB after every burst, in four runs of three bursts each. With inline_dispatch a burst needs no blocking threads for its plugin calls, and the same gateway stays at 80–94 MiB.
  • Sessions that start together need threads too. A legacy session's store writes run on the blocking pool: with the tuned settings, 512 sessions that started at once took one gateway on NATS from 12–13 to 241–244 threads and from about 170 MiB to 850–865 MiB; alone, the same 512 sessions cost 4 MiB. Each such thread holds about 3 MiB.
  • Plan a gateway's memory for its peak at the concurrency it serves.

The coordinator store

tools/bench/cluster/kv.sh: each coordinator plugin's own store, without the gateway and without the plugin boundary, on fresh servers for each backend and keyspace. A seed of 100 000 keys with 256-byte values (1 000 of them under one prefix), then each operation alone for 5 s from 1 and from 64 tasks. NATS R1 is one node, NATS R3 three nodes on one host, Redis one server without persistence.

operationwhat the gateway uses it forNATS R1: p50 at 1 task, per second at 64NATS R3Redis
geta session, task or subscription read201 µs, 29 400/s391 µs, 32 300/s100 µs, 143 500/s
get of a missing keya lookup that finds nothing204 µs, 29 700/s372 µs, 32 700/s97 µs, 239 900/s
puta row write with a TTL201 µs, 37 400/s595 µs, 20 400/s119 µs, 73 200/s
incra rate-limit, budget or event-inbox counter376 µs, 15 400/s972 µs, 11 400/s117 µs, 90 200/s
put_if_absenta concurrency slot, an idempotency claim, a recorded session end197 µs, 29 100/s576 µs, 17 400/s125 µs, 65 600/s
deletea row removed388 µs, 18 500/s952 µs, 15 800/s109 µs, 111 300/s
a full pass over the 1 000 keys of a prefixa sweep, a subscriber fan-out37 ms; 28 passes/s, 16 at once40 ms; 32 passes/s3.7 ms; 559 passes/s
  • The keyspace hardly matters. From 10 000 to 1 000 000 keys every operation stays within 25 % of its rate; the p99 of a full pass over a prefix grows 1.8× on NATS and 2.2× on Redis at one task, and not at all with 16 passes at once.
  • On NATS an incr and a delete cost two round trips: a delete reads the key, then writes its delete marker; an incr reads the counter and writes it back on the condition that nothing else wrote it first, and retries when something did. Many writers of one counter wait for one another: under 64 tasks its p99 is 23 ms on R1 and 31 ms on R3.
  • Replication costs writes, not reads. On three nodes a read takes twice as long as on one node at one task, and a server serves as many at 64 tasks; a write takes 2.5–3 times as long.
  • Storage: 553–557 bytes a key on NATS (the value and an index record) and 749–815 on Redis. The default NATS state bucket of 256 MiB (jetstream.state_bucket_max_bytes) holds about 480 000 such keys.

Inside one process

The nightly benchmark workflow (benchmark.yml) measures the pieces of a call alone, on the CI runners: self-hosted x86_64 pods that other jobs share. Instruction and allocation counts do not depend on the host; the times do, so compare them only with the same lane.

The plugin boundary

libs/plugin-host/benches: a call to the no-op bench-noop plugin through the real dlopened cdylib vtable, against the same call in process.

per call
instructions in process / across the cdylib boundary5 476 / 31 856 (26 380 for the boundary)
heap across the boundary18 allocations, 2 756 bytes
backend.execute in process / across the boundary through the blocking pool0.14 µs / 29.6 µs
a gate, transform, identity or log-sink slot call through the blocking pool / inline28–33 µs / 1.0–1.9 µs
encode a 256-byte payload: typed and borrowed / typed and owned / bincode / JSON with base643.3 ns / 44 ns / 0.71 µs / 1.33 µs
encode a 4 KiB payload, the same four3.4 ns / 159 ns / 10.7 µs / 10.1 µs
load a cdylib and make its backend1.94 ms

A plugin call costs about 30 µs through the blocking pool and 1–2 µs inline: the hand-off to a blocking-pool thread, not the boundary's marshaling, is most of a ferried call. That is what inline_dispatch saves on every call, and why the tuned figures need about 10 threads.

Request costs

apps/gateway/benches and the SQL backend's benches, criterion medians:

median
parse a tools/list, prompts/list or resources/list request0.68–0.69 µs
parse a notifications/cancelled1.08 µs
redact credentials in a small payload, clean / with secrets0.93 / 0.52 µs
redact 20 nested events / 10 nesting levels / 50 strings with JWT detection81.7 / 4.8 / 14.2 µs
normalize a resource URI (HTTP, HTTPS, a custom scheme, a URN, a data URI)0.40–0.81 µs
normalize a URI with a 50-segment path2.55 µs
SQL backend, in-memory SQLite: one SELECT 1 / 8 calls at once on a pool of 2139 / 611 µs

End to end in one process

apps/gateway/tests/bench_*.rs (#[ignore]d), run on the benchmark host, not the CI runners: the gateway and a reqwest driver in one process pinned to 12 cores, a mock binding, the default settings.

The gateway against the backend's latency (bench_backend_latency):

the backend takes10 sessions50 sessions100 sessionsthe gateway's share of p50, 50 sessions
0 ms6 452/s6 424/s6 645/sall of it (7.7 ms, the queue)
5 ms1 439/s6 329/s10 610/s2.8 ms
50 ms192/s959/s1 909/s2.2 ms (4 %)
200 ms50/s250/s500/s2.5 ms (1.2 %)
500 ms20/s100/s200/s2.8 ms (0.6 %)

With a backend of 50 ms or more, the gateway adds 2–3 ms at p50, and a deployment serves its sessions over the backend's time: 100 sessions on a 200 ms backend, 500 calls a second.

Sustained load (bench_load), each session calling tools/call as fast as it can for 10 s:

sessionscalls/sp50 / p95 / p99longest callerrorsmemory, before → under load
10020 4723.8 / 6.5 / 7.8 ms17 ms012 → 50 MiB
2506 53120.1 / 25.0 / 1 037 ms7.7 s042 → 55 MiB
5006 70419.4 / 26.8 / 1 043 ms15.3 s25 (0.04 %)55 → 63 MiB
1 0006 06921.9 / 57.2 / 3 928 ms16.4 s363 → 87 MiB

From 250 sessions on, the kernel's listen queue overflowed: 1 615 times in one 250-session run. A connection the queue drops is tried again after 1, 3, 7 and 15 s, which is the p99 and the longest calls of those rows. The fleet benchmark's load generator keeps its connections open, and with 512 of them on one gateway the p99 stayed under 100 ms.

Plugins, the debug tools and the number of bindings (bench_plugins, 100 sessions, 10 s each):

scenariocalls/sp50 / p99
no plugins20 1543.8 / 8.0 ms
one no-op audit, cache or guardrails plugin18 390–20 1963.8–4.2 / 7.9–8.7 ms
three of them stacked19 5044.0 / 8.2 ms
the debug tools off / on20 262 / 21 0953.8 / 7.9 ms; 3.7 / 7.6 ms
1, 10, 50 or 100 bindings20 711–21 5243.6–3.7 / 7.4–7.8 ms

In-process plugins, the debug tools and the number of bindings change the rate by less than 10 %.

Sessions (bench_gateway): one client runs a session's lifecycle (initialize, tools/call, delete) in 0.67 ms at p50 and 0.85 ms at p99, 1 457 a second; tools/list answers in 0.16 ms at p50, 5 690 a second. With 10 to 500 clients each running full lifecycles (initialize, tools/list, ten tools/call, delete), the gateway serves 3 200–3 800 requests a second.

Memory per session (bench_backends bench_memory, nightly on the CI runners): 56 MiB at idle, 113 MiB with 256 sessions, about 230 KiB a session with the warm-up; 5 MiB above the start after the sessions drained.

The proxy lane

tools/bench/proxy-load on a 48-core CI runner, nightly: the legacy wire with the default settings, 32 sessions, 1 KiB results, the gateway on the runner's cores without pinning.

loadcalls/sp50 / p90 / p99gateway CPU, average / peakmemory, average / peak
as fast as the sessions go5 5545.5 / 6.3 / 10.1 ms3.5 / 3.8 cores146 / 161 MiB
a fixed 3 000 calls a second3 0007.0 / 9.0 / 13.7 ms2.3 / 3.9 cores171 / 187 MiB

A larger machine

On the isolated host, the small shape is the layout of the tables above. The large shape gives each gateway 4 cores (one gateway alone 8), the three NATS nodes 8 shared cores for tool calls and 12 for MCP Events, and the load generator 8 cores for tool calls and 4 for MCP Events. The cores are of one type in both shapes: the large shape adds cores, and its 32 vCPU run on 16 physical cores, but no core is faster. Tool calls: 20 s runs, two a figure; MCP Events as in MCP Events, two runs; the range of the runs.

small shapelarge shape
one gateway alone, tool calls a second, default: stateless / legacy5 354–5 533 / 4 001–4 21211 787–12 047 / 10 014–10 333
one gateway alone, tuned: stateless / legacy8 348–10 048 / 8 186–8 56513 585–14 040 / 13 990–14 329
four gateways on NATS R3, default: stateless / legacy10 131–10 460 / 2 879–2 95513 244–13 481 / 4 760–4 853
four gateways on NATS R3, tuned: stateless / legacy16 483–16 812 / 15 902–16 51015 291–15 468 / 14 625–15 462
MCP Events, four gateways on NATS R3, 1 000 offered ⁵772–817 a second, 67–70 % within 3 s999–1 000, 81–85 %
MCP Events, four gateways on NATS R3, 1 600 offered ⁵435, 27 %553–638, 41–49 %

⁵ Both shapes on nats-server 2.10.24, whose subject-filtered purges hold up the bucket's writes during a sweep. On 2.14.8 the small shape takes 829–865 a second at 1 000 offered, 89–91 % within 3 s (see MCP Events).

  • One gateway alone more than doubles on 8 cores: 11 800–12 000 stateless calls a second with the default settings, at p50 5.3 ms, while it used 5.0 of its 8 cores. The 64 connections of the load generator set that figure, not the gateway.
  • A coordinator with more cores carries more sessions. A legacy call on NATS with the default settings writes its session to the coordinator: the NATS nodes on 8 cores carried 4 760–4 853 a second, 1.6 times what they carried on 2.
  • Tuned, four gateways serve 14 600–16 800 calls a second in both shapes: the 64 connections of the load generator are the limit there.
  • MCP Events on NATS 2.10.24 take a quarter to a half more in the large shape, while the NATS nodes used 3.6–5.1 of their 12 cores: more coordinator cores raise the events rate less than their count.

Sizing

The figures above give five relations to size a deployment with. Take the four-gateway figures: a gateway in a fleet serves fewer calls for the same fixed work than one alone.

  1. Gateway cores = calls a second × CPU per call. One core serves about 2 800 calls a second with the tuned settings (350 µs a call), about 2 300 stateless calls with the default settings (440 µs), and about 1 000 legacy calls with the default settings (950 µs). Keep a gateway under about 70 % of its cores at peak.
  2. Coordinator load = calls a second × messages per call. Stateless calls and tuned legacy calls send it about nothing. Legacy calls with the default settings send 6–8 messages each with replication: three NATS nodes on 2 cores carried about 2 700 of them a second (about 17 000 messages a second) at 1.67 cores. Size NATS for that traffic, or turn on unary_json_fast_path. The store's own ceiling, three NATS nodes on one host: about 30 000 reads and 11 000–20 000 writes a second; one Redis server: 140 000 reads and 65 000–110 000 writes.
  3. Memory = idle base + working set at the concurrency served. About 60–190 MiB at idle; at 64 concurrent calls 520–690 MiB with the default settings (the legacy wire on NATS at the top) and 105–160 MiB with inline_dispatch. A blocking-pool thread holds about 3 MiB while it lives: one per plugin call in flight with the default settings, and one per store call in flight when many sessions start together. Plan for the peak: a burst's memory goes back after it, but a gateway alone that idles can hold it for a minute or more.
  4. MCP Events ingest is per deployment, delivery is per gateway. One events owner takes the inbound events: about 1 600 a second on one gateway alone with the file store, all 1 000 offered on four gateways over Redis, and about 400 with every delivery within 3 s on NATS R3 with 2 coordinator cores (about 850 accepted, with the streams behind); keep the offered rate under that, as past it the gateways refuse posts and the streams fall behind. Deliveries cost 17–207 µs of gateway CPU each, wherever the streams are. A NATS coordinator for MCP Events runs nats-server 2.14 or later.
  5. Limits are cheap when they refuse. A refusal answers from the replica's memory; only an admitted call under a fleet-wide cap costs coordinator operations.

A worked example: 20 000 tool calls a second on the stateless wire with the default settings is about 20 000 × 441 µs ≈ 8.8 cores of gateway CPU, so about seven gateways of 2 cores at 70 %, and a small coordinator. The same load on the legacy wire needs unary_json_fast_path, or a coordinator sized for about 125 000 messages a second.

Reproduce

bash
# One gateway alone, then one, two and four on three NATS nodes (prebuilt binaries optional):
COORDINATOR=none GATEWAYS=1 SCENARIOS="scale governed churn listen watch memtime" DUR=30 \
  tools/bench/cluster/fleet.sh
COORDINATOR=nats GATEWAYS="1 2 4" SCENARIOS=scale DUR=30 tools/bench/cluster/fleet.sh
COORDINATOR=nats GATEWAYS=4 SCENARIOS="governed churn listen watch failover" DUR=30 \
  tools/bench/cluster/fleet.sh
# The tuned settings; Redis:
FLEET_TUNED=1 SCENARIOS=scale DUR=30 tools/bench/cluster/fleet.sh
COORDINATOR=redis GATEWAYS="1 4" SCENARIOS=scale DUR=30 tools/bench/cluster/fleet.sh
# MCP Events at several rates, with the coordinator calls of each event (the Prometheus plugin
# in PLUGIN_DIR); COORDINATOR=none, nats or redis, GATEWAYS=1 or 4:
FLEET_METRICS=1 SCENARIOS=events EVENT_RATES="100 400 1000 1600 3200" DUR=30 \
  tools/bench/cluster/fleet.sh
# The large shape of a larger machine, for tool calls and for MCP Events:
GATEWAY_CPUS_EACH=4 COORDINATOR_CPUS=16-23 LOADGEN_CPUS=24-31 GATEWAYS="1 4" SCENARIOS=scale \
  SCALE_REPS=2 DUR=20 tools/bench/cluster/fleet.sh
GATEWAY_CPUS_EACH=4 COORDINATOR_CPUS=16-27 LOADGEN_CPUS=28-31 GATEWAYS=4 FLEET_METRICS=1 \
  SCENARIOS=events EVENT_RATES="100 400 1000 1600 3200" DUR=30 tools/bench/cluster/fleet.sh
# The tables of a run:
python3 tools/bench/cluster/fleet_report.py "$OUT_DIR/fleet.jsonl"
# The coordinator store:
tools/bench/cluster/kv.sh
# Inside one process, as the nightly workflow runs them:
mkdir -p bench-results
for lane in ffi-iai criterion integration budgets proxy; do
  tools/bench/run-bench.sh "$lane" 2>&1 | tee "bench-results/$lane.log"
done
node tools/bench/gen-report.mjs bench-results
# The end-to-end benches on one host, as in the tables above (also bench_plugins,
# bench_backend_latency, bench_gateway):
taskset -c 0-11 cargo test -p mcpg --release --test bench_load -- \
  --ignored --nocapture --test-threads=1

tools/bench/cluster/README.md names every variable of the fleet and store benchmarks, and docs/ci/BENCHMARKING.md the nightly lanes. Compare runs on one host and at similar steal; the CPU per call and the coordinator messages per call are what carry over.