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 cores | four gateways on NATS R3, 2 cores each | |
|---|---|---|
| Tool calls a second, default settings | 4 801 stateless, 3 811 legacy | 9 773 stateless, 2 745 legacy |
| Tool calls a second, tuned settings | 7 718 stateless, 7 727 legacy | 14 772 stateless, 14 872 legacy ¹ |
| Gateway CPU per tool call | 251–432 µs | 350–952 µs |
| Notifications, p50 / p99 | 4 / 7 ms | 17 / 51 ms |
| MCP Events into 64 push streams ² | about 1 600 events/s in, up to 107 000 deliveries/s out | about 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 connections | 586 MiB default, 104 MiB tuned | 385 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_pathtakes 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
| Host | one VM: AMD EPYC 7502P, 16 vCPU, 62 GiB RAM, virtual SSD (fsync p50 0.9 ms, p99 3.3 ms), Linux 6.1 |
| Isolated host | for 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) |
| Gateway | an optimized build (release images add fat LTO and one codegen unit); each gateway on 2 cores of its own |
| Coordinator | nats-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) |
| Load | mcp-loadgen (tools/bench/proxy-load) on 4 cores: 64 connections, each sends its next request when the last one is answered |
| Tool | a mock backend plugin (cdylib) that answers at once; every call is audited to the built-in local file sink, as by default |
| Runs | 30 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
flowchart LR
C["MCP client"] -->|"POST /mcp"| T["HTTP transport<br/>validation, access log"]
T --> I["Identity<br/>API key, OIDC, mTLS"]
I --> G["Policy, guards,<br/>limits"]
G --> D["Dispatcher"]
D --> P["Backend plugin<br/>cdylib or built-in"]
P --> U[("Upstream")]
D -. "awaited: allowed and<br/>completed events" .-> A["Audit sink"]
G -. "fleet counters:<br/>concurrency, rate, budget" .-> K[("Coordinator<br/>NATS or Redis")]
T -. "legacy wire only:<br/>session row, replay" .-> K
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).
| fleet | wire | calls/s | p50 / p99 | gateway CPU per call | NATS messages per call | memory |
|---|---|---|---|---|---|---|
| one gateway alone | stateless (2026-07-28) | 4 801 | 13.0 / 20.9 ms | 338 µs | — | 586 MiB |
| one gateway alone | legacy (2025-11-25) | 3 811 | 16.3 / 26.7 ms | 432 µs | — | 637 MiB |
| one gateway, NATS R3 | stateless | 4 695 | 13.3 / 22.1 ms | 338 µs | 0.04 | 764 MiB |
| one gateway, NATS R3 | legacy | 2 153 | 28.8 / 53.3 ms | 718 µs | 7.6 | 801 MiB |
The same load with the three settings of Settings that change throughput on:
| fleet | wire | calls/s | p50 / p99 | gateway CPU per call | NATS messages per call | memory |
|---|---|---|---|---|---|---|
| one gateway alone | stateless | 7 718 | 7.9 / 12.1 ms | 251 µs | — | 104 MiB |
| one gateway alone | legacy | 7 727 | 8.2 / 12.0 ms | 258 µs | — | 105 MiB |
| one gateway, NATS R3 | stateless | 7 791 | 8.1 / 12.4 ms | 256 µs | 0.02 | 374 MiB |
| one gateway, NATS R3 | legacy | 7 537 | 8.4 / 13.0 ms | 265 µs | 0.02 | 501 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.
| Setting | What it skips | What it gives up | Use it when |
|---|---|---|---|
gateway.server.unary_json_fast_path: true | On 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 both | Your clients use the 2025-11-25 wire, above all on a fleet |
gateway.server.access_log: false | The two structured log events (request received, request completed) of every request | The per-request access log; audit events, warnings, errors, metrics and traces stay | An ingress or a sidecar logs the requests already |
plugins[].inline_dispatch: true | The spawn_blocking hand-off and the per-call timeout of the plugin's slot calls: the call runs on the caller's thread | A slow or hung plugin holds a runtime worker, with no timeout to stop it | Trusted, in-process, fast plugins only |
What the three do together, one gateway on NATS R3:
| wire | settings | calls/s | p50 / p99 | CPU per call | coordinator cores | NATS messages per call |
|---|---|---|---|---|---|---|
| stateless | default | 4 695 | 13.3 / 22.1 ms | 338 µs | 0.02 | 0.04 |
| stateless | tuned | 7 791 | 8.1 / 12.4 ms | 256 µs | 0.01 | 0.02 |
| legacy | default | 2 153 | 28.8 / 53.3 ms | 718 µs | 1.26 | 7.6 |
| legacy | tuned | 7 537 | 8.4 / 13.0 ms | 265 µs | 0.02 | 0.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.
| gateways | settings | wire | calls/s | p50 / p99 | CPU per call | cores per gateway (of 2) | coordinator cores (of 2) | NATS messages per call |
|---|---|---|---|---|---|---|---|---|
| 1 | default | stateless | 4 695 | 13.3 / 22.1 ms | 338 µs | 1.58 | 0.02 | 0.04 |
| 2 | default | stateless | 7 798 | 8.0 / 12.6 ms | 377 µs | 1.47 | 0.02 | 0.03 |
| 4 | default | stateless | 9 773 | 6.3 / 13.4 ms | 441 µs | 1.07 | 0.02 | 0.02 |
| 1 | default | legacy | 2 153 | 28.8 / 53.3 ms | 718 µs | 1.55 | 1.26 | 7.6 |
| 2 | default | legacy | 2 756 | 21.6 / 50.5 ms | 855 µs | 1.14 | 1.61 | 5.8 |
| 4 | default | legacy ² | 2 745 | 22.3 / 52.3 ms | 952 µs | 0.65 | 1.67 | 6.2 |
| 1 | tuned | stateless | 7 791 | 8.1 / 12.4 ms | 256 µs | 2.00 | 0.01 | 0.02 |
| 2 | tuned | stateless | 14 146 | 4.4 / 7.4 ms | 272 µs | 1.94 | 0.02 | 0.01 |
| 4 | tuned | stateless | 14 772 | 4.2 / 6.6 ms | 354 µs | 1.31 | 0.02 | 0.01 |
| 1 | tuned | legacy | 7 537 | 8.4 / 13.0 ms | 265 µs | 2.00 | 0.02 | 0.02 |
| 2 | tuned | legacy | 13 772 | 4.5 / 7.3 ms | 280 µs | 1.93 | 0.02 | 0.02 |
| 4 | tuned | legacy | 14 872 | 4.2 / 6.7 ms | 350 µs | 1.29 | 0.03 | 0.02 |
---
config:
themeVariables:
xyChart:
plotColorPalette: "#3987e5, #d95926, #199e70"
---
xychart-beta
title "Calls per second by fleet size"
x-axis ["1 gateway", "2 gateways", "4 gateways"]
y-axis "calls/s" 0 --> 16000
line [4695, 7798, 9773]
line [2153, 2756, 2745]
line [7537, 13772, 14872]
² 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.
| gateways | settings | wire | calls/s | p50 / p99 | CPU per call | cores per gateway (of 2) | Redis cores (of 2) |
|---|---|---|---|---|---|---|---|
| 1 | default | stateless | 4 968 | 12.6 / 19.6 ms | 335 µs | 1.66 | 0.00 |
| 1 | default | legacy | 2 765 | 22.9 / 37.1 ms | 681 µs | 1.88 | 0.15 |
| 4 | default | stateless | 10 090 | 6.2 / 8.7 ms | 432 µs | 1.10 | 0.00 |
| 4 | default | legacy | 7 796 | 8.0 / 12.5 ms | 800 µs | 1.56 | 0.49 |
| 4 | default | legacy, each call on the next replica | 6 101 | 9.0 / 24.4 ms | 835 µs | 1.27 | 0.38 |
| 1 | tuned | stateless | 8 388 | 7.5 / 11.6 ms | 238 µs | 2.00 | 0.00 |
| 1 | tuned | legacy | 8 037 | 7.8 / 12.1 ms | 248 µs | 2.00 | 0.00 |
| 4 | tuned | stateless | 14 816 | 4.2 / 6.4 ms | 354 µs | 1.31 | 0.00 |
| 4 | tuned | legacy | 14 815 | 4.2 / 6.7 ms | 353 µs | 1.30 | 0.00 |
| 4 | tuned | legacy, each call on the next replica | 14 356 | 4.3 / 8.1 ms | 359 µs | 1.27 | 0.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.
| fleet | sessions ended a second | stream end p50 / p99 | stuck streams |
|---|---|---|---|
| one gateway alone | 3 291 | 0.6 / 1.5 ms | 0 |
| one gateway, NATS R3 | 311–361 | 9.5–11.3 / 207–220 ms | 0 |
| four gateways, NATS R3 | 362 | 18 / 46 ms | 0 |
| one gateway, Redis | 770 | 4.8 / 8.3 ms | 0 |
| four gateways, Redis | 1 366 | 2.2 / 6.2 ms | 0 |
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.
| fleet | notifications | p50 / p99 |
|---|---|---|
| one gateway alone | 38 464 of 38 464 | 4 / 7 ms |
| one gateway, NATS R3 | 38 464 of 38 464 | 22 / 49 ms |
| four gateways, NATS R3 | 38 464 of 38 464 | 17 / 51 ms |
| four gateways, Redis | 38 464 of 38 464 | 10 / 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.
| fleet | changes told | told twice | missed | delay p50 / p99 |
|---|---|---|---|---|
| one gateway alone | every one | 0 | 0 | 5.1 / 10.0 s |
| one gateway, NATS R3 | every one | 0 | 0 | 4.9 / 9.9 s |
| four gateways, NATS R3 | every one | 0 | 0 | 5.2 / 10.0 s |
| four gateways, one killed (SIGKILL) halfway | every one | 0 | 0 | 5.3 / 28.4 s |
| four gateways, Redis | every one | 0 | 0 | 4.9 / 9.9 s |
| four gateways, Redis, one killed halfway | every one | 0 | 0 | 5.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:
flowchart LR
A["A call under a fleet cap of N"] --> L{"A local slot free?<br/>N slots per replica"}
L -- "no" --> R1["Refused here,<br/>no store operation"]
L -- "yes" --> I["Take a free slot key<br/>in the coordinator"]
I --> S{"A slot key free<br/>of the N?"}
S -- "yes" --> OK["Admitted; the key and<br/>the local slot go back at the end"]
S -- "no" --> R2["Refused; for 20 ms the replica<br/>refuses at once, without a store operation"]
- 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.
| limit | check | one gateway alone | one gateway, NATS R3 | four gateways, NATS R3 (2 runs) | four gateways, Redis (3 runs) |
|---|---|---|---|---|---|
| 8 calls in flight, each 100 ms | at most 80 calls/s | 74.9/s | 67.2/s | 69.9–70.3/s | 73.9–74.7/s |
| 600 calls/min, burst 10 | at most 10 + 10/s × 30 s = 310 | 310 | 310 | 310 | 310 |
| a budget of 1 000 calls | at most 1 000 | 1 000 | 1 000 | 1 000 | 1 000 |
| calls judged a second, refused included | — | 10 400–11 800 | 9 500–12 500 | 19 200–27 400 | 21 800–29 200 |
| coordinator cores (of 2) | — | — | 0.08–0.47 | 0.17–1.49 | 0.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
sequenceDiagram
autonumber
participant P as Producer
participant R as Inbound route (any replica)
participant K as Coordinator KV
participant O as Events owner
participant S as Push streams (each replica)
P->>R: POST /webhooks/events/{source}
R->>K: reserve an inbox slot, write the row
R-->>P: 202 Accepted
R--)O: hint: the inbox has rows
O->>K: read up to 256 inbox rows, 16 at once
O->>K: dedup markers, rows, the head after every 32 rows
O--)S: hint: the head moved
S->>K: the bounds and each new row, once per replica
S-->>S: every stream of the replica reads them from memory
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.
| fleet | events offered/s | accepted/s | deliveries within 3 s | p50 / p99 | gateway CPU per delivery | coordinator cores (of 2) | NATS messages per event |
|---|---|---|---|---|---|---|---|
| one gateway alone | 100 | 100 | 192 256 of 192 256 | 5 / 13 ms | 37 µs | — | — |
| 400 | 400 | 768 256 of 768 256 | 10 / 42 ms | 35 µs | — | — | |
| 1 000 | 1 000 | 1 920 640 of 1 920 640 | 12 / 54 ms | 24 µs | — | — | |
| 1 600 | 1 597 | 3 066 304 of 3 066 304 | 110 / 1 233 ms | 17 µs | — | — | |
| 3 200 | 1 804, 41 772 refused | 93 % | 6 010 / 6 417 ms | 17 µs | — | — | |
| one gateway, NATS R3 ³ | 100 | 100 | all | 16–17 / 34–36 ms | 87–98 µs | 0.50–0.56 | 104–108 |
| 400 | 400 | all | 27–29 / 928–1 355 ms | 59–61 µs | 1.28–1.29 | 82–84 | |
| 1 000 | 948–999, 54–1 556 refused | 75–77 % | 5 415–7 261 / 9 969–11 522 ms | 36–38 µs | 1.45–1.46 | 52 | |
| 1 600 | 808–864, 22 104–23 768 refused | 69 % | 12 510–12 879 / 13 811–14 983 ms | 47–50 µs | 1.40–1.42 | 54–55 | |
| 3 200 | 640–683, 71 759–75 552 refused | 61–63 % | 13 609–15 848 / 18 835–19 846 ms | 66–74 µs | 1.51–1.56 | 76 | |
| four gateways, NATS R3 ³ | 100 | 100 | all | 18–19 / 33–39 ms | 193–207 µs | 0.83 | 149–152 |
| 400 | 398 | all | 31–32 / 197–236 ms | 101–106 µs | 1.66–1.67 | 107–108 | |
| 1 000 | 829–865, none refused | 89–91 % | 2 174–3 375 / 6 590–6 684 ms | 64–69 µs | 1.81–1.82 | 63 | |
| 1 600 | 752–849, 7 396–15 471 refused | 67–72 % | 8 011–8 174 / 13 670–16 964 ms | 81–89 µs | 1.87–1.89 | 60–71 | |
| 3 200 | 567–628, 77 202–78 670 refused | 58–63 % | 19 351–19 853 / 20 543–22 143 ms | 129–150 µs | 1.86–1.87 | 84–97 | |
| four gateways, Redis | 100 | 100 | 192 256 of 192 256 | 6 / 10 ms | 117 µs | 0.06 | — |
| 400 | 400 | 768 256 of 768 256 | 12 / 83 ms | 92 µs | 0.19 | — | |
| 1 000 | 1 000 | 1 920 640 of 1 920 640 | 7 / 120 ms | 71 µs | 0.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 %.
---
config:
themeVariables:
xyChart:
plotColorPalette: "#3987e5, #d95926, #199e70"
---
xychart-beta
title "Deliveries a second to 64 streams"
x-axis ["100 offered", "400 offered", "1000 offered", "1600 offered", "3200 offered"]
y-axis "deliveries/s" 0 --> 110000
bar [6408, 25608, 64021, 102210, 107534]
line [6408, 25608, 49043, 35858, 25088]
line [6408, 25446, 47017, 32101, 21196]
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 gets503with aRetry-Afterat 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 gets503or429should retry after theRetry-Afterit 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:
| settings | calls/s | p50 / p99 | threads at idle → under load | memory at idle → peak | memory 60 s later |
|---|---|---|---|---|---|
| default (4 runs) | 5 000–5 744 | 10.8–12.5 / 17.0–19.9 ms | 6–8 → 97–106 | 61–63 → 522–579 MiB | 136–543 MiB |
tuned (inline_dispatch on the backend) | 8 126 | 7.7 / 12.3 ms | 8 → 8 | 61 → 105 MiB | 82 MiB |
SCENARIOS=memtime: three bursts of 30 s on one gateway, with 120, 60 and 120 s without load
after them:
| fleet | settings | burst | calls/s | memory before | peak | after the idle time |
|---|---|---|---|---|---|---|
| one gateway alone | default | stateless | 5 141 | 61 MiB | 567 MiB | 140 MiB |
| stateless again | 4 729 | 140 MiB | 516 MiB | 140 MiB | ||
| legacy | 3 505 | 133 MiB | 565 MiB | 158 MiB | ||
| tuned | stateless | 7 746 | 63 MiB | 105 MiB | 83 MiB | |
| stateless again | 7 839 | 83 MiB | 104 MiB | 80 MiB | ||
| legacy | 7 767 | 80 MiB | 104 MiB | 83 MiB | ||
| one gateway, NATS R3 | default | stateless | 4 712 | 74 MiB | 547 MiB | 137 MiB |
| stateless again | 4 834 | 137 MiB | 518 MiB | 141 MiB | ||
| legacy | 2 309 | 149 MiB | 689 MiB | 186 MiB | ||
| tuned | stateless | 7 217 | 75 MiB | 112 MiB | 80 MiB | |
| stateless again | 7 652 | 80 MiB | 113 MiB | 87 MiB | ||
| legacy | 8 255 | 91 MiB | 161 MiB | 94 MiB |
---
config:
themeVariables:
xyChart:
plotColorPalette: "#3987e5, #d95926, #199e70"
---
xychart-beta
title "Resident memory through a 30 s burst (MiB)"
x-axis "seconds from the burst's start" [0, 4, 10, 16, 22, 28, 32, 34, 40, 50, 60, 70, 80, 88]
y-axis "MiB" 0 --> 600
line [63, 498, 571, 543, 543, 552, 543, 543, 543, 543, 543, 543, 543, 543]
line [61, 552, 531, 529, 527, 534, 495, 137, 137, 137, 137, 137, 137, 136]
line [61, 104, 104, 104, 104, 103, 84, 84, 84, 84, 82, 82, 82, 82]
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_blockinghand-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
memtimeruns), 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_dispatcha 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.
| operation | what the gateway uses it for | NATS R1: p50 at 1 task, per second at 64 | NATS R3 | Redis |
|---|---|---|---|---|
get | a session, task or subscription read | 201 µs, 29 400/s | 391 µs, 32 300/s | 100 µs, 143 500/s |
get of a missing key | a lookup that finds nothing | 204 µs, 29 700/s | 372 µs, 32 700/s | 97 µs, 239 900/s |
put | a row write with a TTL | 201 µs, 37 400/s | 595 µs, 20 400/s | 119 µs, 73 200/s |
incr | a rate-limit, budget or event-inbox counter | 376 µs, 15 400/s | 972 µs, 11 400/s | 117 µs, 90 200/s |
put_if_absent | a concurrency slot, an idempotency claim, a recorded session end | 197 µs, 29 100/s | 576 µs, 17 400/s | 125 µs, 65 600/s |
delete | a row removed | 388 µs, 18 500/s | 952 µs, 15 800/s | 109 µs, 111 300/s |
| a full pass over the 1 000 keys of a prefix | a sweep, a subscriber fan-out | 37 ms; 28 passes/s, 16 at once | 40 ms; 32 passes/s | 3.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
incrand adeletecost two round trips: adeletereads the key, then writes its delete marker; anincrreads 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 boundary | 5 476 / 31 856 (26 380 for the boundary) |
| heap across the boundary | 18 allocations, 2 756 bytes |
backend.execute in process / across the boundary through the blocking pool | 0.14 µs / 29.6 µs |
| a gate, transform, identity or log-sink slot call through the blocking pool / inline | 28–33 µs / 1.0–1.9 µs |
| encode a 256-byte payload: typed and borrowed / typed and owned / bincode / JSON with base64 | 3.3 ns / 44 ns / 0.71 µs / 1.33 µs |
| encode a 4 KiB payload, the same four | 3.4 ns / 159 ns / 10.7 µs / 10.1 µs |
| load a cdylib and make its backend | 1.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 request | 0.68–0.69 µs |
parse a notifications/cancelled | 1.08 µs |
| redact credentials in a small payload, clean / with secrets | 0.93 / 0.52 µs |
| redact 20 nested events / 10 nesting levels / 50 strings with JWT detection | 81.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 path | 2.55 µs |
SQL backend, in-memory SQLite: one SELECT 1 / 8 calls at once on a pool of 2 | 139 / 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 takes | 10 sessions | 50 sessions | 100 sessions | the gateway's share of p50, 50 sessions |
|---|---|---|---|---|
| 0 ms | 6 452/s | 6 424/s | 6 645/s | all of it (7.7 ms, the queue) |
| 5 ms | 1 439/s | 6 329/s | 10 610/s | 2.8 ms |
| 50 ms | 192/s | 959/s | 1 909/s | 2.2 ms (4 %) |
| 200 ms | 50/s | 250/s | 500/s | 2.5 ms (1.2 %) |
| 500 ms | 20/s | 100/s | 200/s | 2.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:
| sessions | calls/s | p50 / p95 / p99 | longest call | errors | memory, before → under load |
|---|---|---|---|---|---|
| 100 | 20 472 | 3.8 / 6.5 / 7.8 ms | 17 ms | 0 | 12 → 50 MiB |
| 250 | 6 531 | 20.1 / 25.0 / 1 037 ms | 7.7 s | 0 | 42 → 55 MiB |
| 500 | 6 704 | 19.4 / 26.8 / 1 043 ms | 15.3 s | 25 (0.04 %) | 55 → 63 MiB |
| 1 000 | 6 069 | 21.9 / 57.2 / 3 928 ms | 16.4 s | 3 | 63 → 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):
| scenario | calls/s | p50 / p99 |
|---|---|---|
| no plugins | 20 154 | 3.8 / 8.0 ms |
| one no-op audit, cache or guardrails plugin | 18 390–20 196 | 3.8–4.2 / 7.9–8.7 ms |
| three of them stacked | 19 504 | 4.0 / 8.2 ms |
| the debug tools off / on | 20 262 / 21 095 | 3.8 / 7.9 ms; 3.7 / 7.6 ms |
| 1, 10, 50 or 100 bindings | 20 711–21 524 | 3.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.
| load | calls/s | p50 / p90 / p99 | gateway CPU, average / peak | memory, average / peak |
|---|---|---|---|---|
| as fast as the sessions go | 5 554 | 5.5 / 6.3 / 10.1 ms | 3.5 / 3.8 cores | 146 / 161 MiB |
| a fixed 3 000 calls a second | 3 000 | 7.0 / 9.0 / 13.7 ms | 2.3 / 3.9 cores | 171 / 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 shape | large shape | |
|---|---|---|
| one gateway alone, tool calls a second, default: stateless / legacy | 5 354–5 533 / 4 001–4 212 | 11 787–12 047 / 10 014–10 333 |
| one gateway alone, tuned: stateless / legacy | 8 348–10 048 / 8 186–8 565 | 13 585–14 040 / 13 990–14 329 |
| four gateways on NATS R3, default: stateless / legacy | 10 131–10 460 / 2 879–2 955 | 13 244–13 481 / 4 760–4 853 |
| four gateways on NATS R3, tuned: stateless / legacy | 16 483–16 812 / 15 902–16 510 | 15 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 s | 999–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.
- 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.
- 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. - 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. - 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-server2.14 or later. - 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
# 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.