Benchmarks
How far one engine goes
Every release, we put the iii engine alone in virtual machines of fixed size and drive it with real SDK workers until it stops keeping up. These are the results: what each size sustains, what it costs in CPU and memory on the way, and where it runs out.
- Engine
- iii 0.25.0
- Measured
- Oct 20, 2026
- Machine
- Local workstation, 16 CPUs
- Sizes
- 3 VMs and the host, 8 scenarios
Pick a machine size
Every figure and chart below follows it. The other sizes stay in the charts, behind, to compare.
- Calls per second through the engine
- 97 k/s
- Worker to engine to worker and back, 512 calls in flight. p99 17 ms.
- Engine CPU per call
- 20 µs
- About 51k calls a second for each core the engine has.
- Observer activations per second, sustained
- 50 k/s
- Every event delivered. Offer 100 k/s and 74% arrive; the rest is dropped, and counted.
- Calls a worker makes to its own functions
- 4.7 M/s
- In-process, unobserved: the engine spends 0.00 CPU on them.
- Engine memory per connected worker
- 161 KiB
- 5,000 idle workers held: 786 MiB on top of 40 MiB at rest.
- Engine memory per observer
- 10 KiB
- 64,000 observers held: 638 MiB.
AMD Ryzen 7 9800X3D 8-Core Processor · 16 CPUs. A size of N vCPU · M is the engine with N virtual CPUs and an M memory cap, the load generator beside it on 6 vCPUs of its own; how we measure.
Calls
Calls through the engine
A worker calls a function another worker hosts. The call goes to the engine, the engine routes it to the host, and the answer comes back the same way: four WebSocket frames per call.
Callers keep more and more calls in flight, from 1 to 2,048. Throughput grows while the engine has CPU to spare, up to a peak: the size’s capacity.
| Size | Peak | p99 there | CPU per call |
|---|---|---|---|
| 1 vCPU · 512 MiB | 63 k/s | 351 ms | 16 µs |
| 2 vCPU · 1 GiB | 97 k/s | 17 ms | 20 µs |
| 4 vCPU · 2 GiB | 171 k/s | 6.2 ms | 22 µs |
| Host · 8 CPUs | 424 k/s | 8.3 ms | 15 µs |
Calls per second, by calls in flight
64-byte payloads. Each size gets a fresh engine.
| Machine | Calls in flight | Calls per second |
|---|---|---|
| 1 vCPU | 1 | 4.1k |
| 1 vCPU | 8 | 15k |
| 1 vCPU | 32 | 28k |
| 1 vCPU | 128 | 46k |
| 1 vCPU | 512 | 55k |
| 1 vCPU | 2,048 | 63k |
| 2 vCPU | 1 | 3.6k |
| 2 vCPU | 8 | 28k |
| 2 vCPU | 32 | 61k |
| 2 vCPU | 128 | 85k |
| 2 vCPU | 512 | 97k |
| 2 vCPU | 2,048 | 92k |
| 4 vCPU | 1 | 3.6k |
| 4 vCPU | 8 | 27k |
| 4 vCPU | 32 | 97k |
| 4 vCPU | 128 | 160k |
| 4 vCPU | 512 | 171k |
| 4 vCPU | 2,048 | 152k |
| Host | 1 | 28k |
| Host | 8 | 119k |
| Host | 32 | 219k |
| Host | 128 | 330k |
| Host | 512 | 403k |
| Host | 2,048 | 424k |
p99 latency, by calls in flight
| Machine | Calls in flight | p99 latency |
|---|---|---|
| 1 vCPU | 1 | 289 µs |
| 1 vCPU | 8 | 769 µs |
| 1 vCPU | 32 | 2 ms |
| 1 vCPU | 128 | 4.5 ms |
| 1 vCPU | 512 | 70 ms |
| 1 vCPU | 2,048 | 351 ms |
| 2 vCPU | 1 | 313 µs |
| 2 vCPU | 8 | 401 µs |
| 2 vCPU | 32 | 878 µs |
| 2 vCPU | 128 | 2.6 ms |
| 2 vCPU | 512 | 17 ms |
| 2 vCPU | 2,048 | 66 ms |
| 4 vCPU | 1 | 313 µs |
| 4 vCPU | 8 | 416 µs |
| 4 vCPU | 32 | 574 µs |
| 4 vCPU | 128 | 1.4 ms |
| 4 vCPU | 512 | 6.2 ms |
| 4 vCPU | 2,048 | 30 ms |
| Host | 1 | 65 µs |
| Host | 8 | 146 µs |
| Host | 32 | 464 µs |
| Host | 128 | 1 ms |
| Host | 512 | 2.4 ms |
| Host | 2,048 | 8.3 ms |
Observers
What observing a function costs
Every function is observable. Here each called function gains 1 to 64 observers without a filter, so every call activates all of them, fire-and-forget, while 64 callers keep calling.
An activation is a call the engine makes on its own, and it costs about as much as one. So the engine splits the same CPU between the calls and their activations: the more observers per call, the fewer calls fit.
Activations per second, by observers per function
| Machine | Observers per function | Activations per second |
|---|---|---|
| 1 vCPU | 1 | 26k |
| 1 vCPU | 4 | 39k |
| 1 vCPU | 16 | 71k |
| 1 vCPU | 64 | 51k |
| 2 vCPU | 1 | 43k |
| 2 vCPU | 4 | 39k |
| 2 vCPU | 16 | 38k |
| 2 vCPU | 64 | 32k |
| 4 vCPU | 1 | 60k |
| 4 vCPU | 4 | 119k |
| 4 vCPU | 16 | 178k |
| 4 vCPU | 64 | 144k |
| Host | 1 | 110k |
| Host | 4 | 326k |
| Host | 16 | 308k |
| Host | 64 | 217k |
The observed calls, by observers per function
| Machine | Observers per function | Calls per second |
|---|---|---|
| 1 vCPU | 0 | 40k |
| 1 vCPU | 1 | 26k |
| 1 vCPU | 4 | 9.8k |
| 1 vCPU | 16 | 6.3k |
| 1 vCPU | 64 | 5.1k |
| 2 vCPU | 0 | 81k |
| 2 vCPU | 1 | 43k |
| 2 vCPU | 4 | 35k |
| 2 vCPU | 16 | 34k |
| 2 vCPU | 64 | 29k |
| 4 vCPU | 0 | 135k |
| 4 vCPU | 1 | 78k |
| 4 vCPU | 4 | 30k |
| 4 vCPU | 16 | 47k |
| 4 vCPU | 64 | 44k |
| Host | 0 | 251k |
| Host | 1 | 222k |
| Host | 4 | 88k |
| Host | 16 | 70k |
| Host | 64 | 58k |
Delivery
How much it delivers in full
A source like cron or a database hands each event to the observers that asked for it, with obs.emit(). We offer a steady rate, from 1k to 500k events a second over 16 observers, and count what arrives.
Up to some rate, everything does. That rate is the size’s sustained capacity, and past it the engine does not fall over: it delivers what it can and drops the rest, counted.
| Size | Sustained | Past it |
|---|---|---|
| 1 vCPU · 512 MiB | 50 k/s | 75% of 100 k/s |
| 2 vCPU · 1 GiB | 50 k/s | 74% of 100 k/s |
| 4 vCPU · 2 GiB | 100 k/s | 61% of 200 k/s |
| Host · 8 CPUs | 100 k/s | 90% of 200 k/s |
Activations delivered, by events offered
Per second, over 16 observers, on logarithmic scales: a line on the diagonal delivers everything.
| Machine | Events offered per second | Activations delivered per second |
|---|---|---|
| 1 vCPU | 1k | 999 |
| 1 vCPU | 5k | 5k |
| 1 vCPU | 20k | 20k |
| 1 vCPU | 50k | 50k |
| 1 vCPU | 100k | 69k |
| 2 vCPU | 1k | 1000 |
| 2 vCPU | 5k | 5k |
| 2 vCPU | 20k | 20k |
| 2 vCPU | 50k | 50k |
| 2 vCPU | 100k | 68k |
| 4 vCPU | 1k | 1000 |
| 4 vCPU | 5k | 5k |
| 4 vCPU | 20k | 20k |
| 4 vCPU | 50k | 50k |
| 4 vCPU | 100k | 100k |
| 4 vCPU | 200k | 115k |
| Host | 1k | 1000 |
| Host | 5k | 5k |
| Host | 20k | 20k |
| Host | 50k | 50k |
| Host | 100k | 100k |
| Host | 200k | 177k |
Filters
Filters at scale
Observers usually filter: a cron expression, a table, a tenant. Here one function has 16 to 4,000 observers, each filtering on a different value, and every call matches exactly one of them.
What grows is the filtering, not the deliveries: the engine checks every observer’s filter on every call to the function.
Calls per second, by filtered observers on the function
| Machine | Filtered observers | Calls per second |
|---|---|---|
| 1 vCPU | 16 | 20k |
| 1 vCPU | 256 | 19k |
| 1 vCPU | 1,024 | 14k |
| 1 vCPU | 4,000 | 5.6k |
| 2 vCPU | 16 | 40k |
| 2 vCPU | 256 | 36k |
| 2 vCPU | 1,024 | 29k |
| 2 vCPU | 4,000 | 24k |
| 4 vCPU | 16 | 60k |
| 4 vCPU | 256 | 59k |
| 4 vCPU | 1,024 | 64k |
| 4 vCPU | 4,000 | 58k |
| Host | 16 | 153k |
| Host | 256 | 159k |
| Host | 1,024 | 164k |
| Host | 4,000 | 124k |
Memory
What it holds, and how much fits
The engine’s resident memory at rest as idle workers connect and observers pile up. Each VM size’s memory cap is drawn across: where a line would cross it, that size runs out.
| Size | Idle workers | Observers |
|---|---|---|
| 1 vCPU · 512 MiB | ~2,900 fit | ~46,000 fit |
| 2 vCPU · 1 GiB | ~6,300 fit | ~99,000 fit |
| 4 vCPU · 2 GiB | ~12,600 fit | ~262,000 fit |
| Host · 8 CPUs | 168 KiB each | 8 KiB each |
Fits: the size’s cap, less the engine at rest, over the measured cost of one. Real workers register more functions, and real observers carry bigger filters.
Engine memory, by idle workers connected
Each worker announces itself and registers two functions, then only answers pings.
| Machine | Idle workers | Resident memory |
|---|---|---|
| 1 vCPU | 0 | 38 MiB |
| 1 vCPU | 100 | 58 MiB |
| 1 vCPU | 1,000 | 200 MiB |
| 2 vCPU | 0 | 38 MiB |
| 2 vCPU | 100 | 58 MiB |
| 2 vCPU | 1,000 | 201 MiB |
| 2 vCPU | 5,000 | 825 MiB |
| 4 vCPU | 0 | 38 MiB |
| 4 vCPU | 100 | 59 MiB |
| 4 vCPU | 1,000 | 204 MiB |
| 4 vCPU | 5,000 | 837 MiB |
| 4 vCPU | 10k | 1.6 GiB |
| Host | 0 | 43 MiB |
| Host | 100 | 63 MiB |
| Host | 1,000 | 213 MiB |
| Host | 5,000 | 864 MiB |
| Host | 10k | 1.6 GiB |
Engine memory, by observers held
Spread over 16 functions, a small filter each, 1,000 per observing worker.
| Machine | Observers held | Resident memory |
|---|---|---|
| 1 vCPU | 0 | 39 MiB |
| 1 vCPU | 1,000 | 52 MiB |
| 1 vCPU | 4,000 | 77 MiB |
| 1 vCPU | 16k | 205 MiB |
| 2 vCPU | 0 | 40 MiB |
| 2 vCPU | 1,000 | 50 MiB |
| 2 vCPU | 4,000 | 78 MiB |
| 2 vCPU | 16k | 198 MiB |
| 2 vCPU | 64k | 678 MiB |
| 4 vCPU | 0 | 40 MiB |
| 4 vCPU | 1,000 | 50 MiB |
| 4 vCPU | 4,000 | 73 MiB |
| 4 vCPU | 16k | 183 MiB |
| 4 vCPU | 64k | 530 MiB |
| Host | 0 | 44 MiB |
| Host | 1,000 | 53 MiB |
| Host | 4,000 | 76 MiB |
| Host | 16k | 175 MiB |
| Host | 64k | 542 MiB |
Overload
When the targets are slow
Observers whose target takes 20 ms, behind calls that come far faster. Each observer runs 64 activations at a time and queues up to 1,024 more; past that, events are dropped and counted.
The calls hold at 64 in flight while slow observers are added: 4, 64, 256. Every one of them keeps a full queue. A queued event is held once, however many observers wait on it, and all the queues together are capped at 64 MiB of events.
Peak engine memory, by slow observers
| Machine | Slow observers on the function | Peak resident memory |
|---|---|---|
| 1 vCPU | 4 | 51 MiB |
| 1 vCPU | 64 | 104 MiB |
| 1 vCPU | 256 | 278 MiB |
| 2 vCPU | 4 | 52 MiB |
| 2 vCPU | 64 | 123 MiB |
| 2 vCPU | 256 | 329 MiB |
| 4 vCPU | 4 | 53 MiB |
| 4 vCPU | 64 | 137 MiB |
| 4 vCPU | 256 | 516 MiB |
| Host | 4 | 64 MiB |
| Host | 64 | 170 MiB |
| Host | 256 | 553 MiB |
Local calls
Calls that never leave the worker
A worker calling a function it hosts runs it in its own process. The engine hears about such a call only when an observer’s filter matches it, in batched reports.
16 tasks in one worker call its own function as fast as they can: unobserved, with an observer matching 1% of the calls, and with one matching them all.
Local calls per second
2 vCPU · 1 GiB
- Unobserved4.7 M/s
engine 0.00 CPU · nothing on the wire
- Observed, 1% match3.6 M/s
engine 0.95 CPU · 36 k/s activations delivered
- Observed, 100% match2.7 M/s
engine 1.12 CPU · 41 k/s activations delivered
Micro-benchmarks
Single paths, every release
Criterion timings of single code paths, in one process, on every release since v0.11.6: a standard GitHub runner, 4 cores. Lower is faster; each line is a benchmark’s history, and its last point is v0.25.0-rc.6, measured Oct 22, 2026.
WebSocket round trip
One call from a worker, through the engine, to another worker and back, over real sockets.
- invoke_echo58 µs56% faster
Observing a call
A call with N observers on it: auto delivers to every one, filtered evaluates every filter and matches one. Fan-out is inside the time.
- auto/01.2 µsnew
- auto/12.7 µsnew
- auto/814 µsnew
- auto/64105 µsnew
- filtered/162.7 µsnew
- filtered/2568.9 µsnew
- filtered/102435 µsnew
Concurrent calls
N calls at once to a function hosted in the engine, waiting for all of them.
- 11.7 µs36% faster
- 812 µs34% faster
- 3254 µs33% faster
- 128214 µs32% faster
Payload size
One call carrying 1 KB to 1 MB of JSON.
- 1kb1.2 µs34% faster
- 10kb1.4 µs40% faster
- 100kb3.1 µs66% faster
- 1mb38 µs56% faster
Trigger fan-out
One event delivered to N triggers at once.
- 123 µs30% faster
- 833 µs37% faster
- 3283 µs30% faster
- 128281 µs34% faster
Registration churn
Registering and removing functions, workers and triggers by the hundred and the thousand.
- functions_register_remove/10040 µs28% faster
- functions_register_remove/1000400 µs27% faster
- functions_register_remove/50002.1 ms28% faster
- triggers_register_unregister/100115 µs14% faster
- triggers_register_unregister/10001.2 ms15% faster
- triggers_register_unregister/50005.9 ms18% faster
- workers_register_unregister/100219 µs11% faster
- workers_register_unregister/10005.9 ms22% faster
- workers_register_unregister/500037 ms18% faster
Core runtime1 benchmark
Calling a handler registered in the engine.
- engine_call_registered_handler1.6 µs29% faster
Key-value contention4 benchmarks
N writers on the same key at once.
- 1234 ns43% faster
- 4980 ns42% faster
- 163.8 µs41% faster
- 6421 µs33% faster
Key-value store10 benchmarks
Reads, writes, updates and listings in the built-in store.
- delete469 ns36% faster
- get_hit262 ns21% faster
- get_miss76 ns32% faster
- list_100_items27 µs22% faster
- list_groups233 ns14% faster
- list_keys_with_prefix322 ns18% faster
- set_overwrite364 ns34% faster
- update_increment344 ns31% faster
- update_merge662 ns34% faster
- update_set_field312 ns43% faster
Metrics accumulator2 benchmarks
- existing_function17 nsnew
- first_invocations11 µsnew
Protocol: decode8 benchmarks
Parsing each wire message from JSON.
- InvocationResult779 ns38% faster
- InvocationResult_Error380 ns36% faster
- InvokeFunction804 ns33% faster
- Ping72 ns29% faster
- Pong71 ns31% faster
- RegisterFunction329 ns55% faster
- RegisterTrigger449 ns37% faster
- WorkerRegistered188 ns37% faster
Protocol: round trip8 benchmarks
Encoding and decoding each wire message.
- InvocationResult1.1 µs37% faster
- InvocationResult_Error543 ns32% faster
- InvokeFunction1.1 µs34% faster
- Ping75 ns36% faster
- Pong74 ns37% faster
- RegisterFunction420 ns54% faster
- RegisterTrigger597 ns35% faster
- WorkerRegistered313 ns27% faster
Protocol: encode8 benchmarks
Turning each wire message into JSON.
- InvocationResult264 ns34% faster
- InvocationResult_Error177 ns22% faster
- InvokeFunction251 ns30% faster
- Ping15 ns50% faster
- Pong16 ns47% faster
- RegisterFunction81 ns60% faster
- RegisterTrigger133 ns35% faster
- WorkerRegistered120 ns18% faster
Pub/sub fan-out4 benchmarks
One message to N subscribers.
- 124 µs29% faster
- 832 µs39% faster
- 3265 µs35% faster
- 128212 µs30% faster
Queue: concurrent producers4 benchmarks
N producers enqueueing at once.
- 14 µs38% faster
- 414 µs38% faster
- 1655 µs38% faster
- 64221 µs37% faster
Queue: payload size4 benchmarks
Enqueueing jobs of 1 KB to 1 MB.
- 1kb3.5 µs39% faster
- 10kb3.8 µs40% faster
- 100kb6 µs54% faster
- 1mb59 µs36% faster
Queue: enqueue1 benchmark
Putting one job on a queue.
- push_single4.1 µs37% faster
Trigger selection7 benchmarks
- empty26 nsnew
- matched_817 µsnew
- mixed_10017 µsnew
- mixed_100017 µsnew
- no_match_100026 nsnew
- register_unregister824 nsnew
- single2.8 µsnew
Worker cleanup9 benchmarks
Tearing down a worker that leaves with its functions and calls in flight.
- functions/105.3 µs22% faster
- functions/5016 µs28% faster
- functions/20053 µs26% faster
- invocations/102.8 µs10% faster
- invocations/503.5 µs±9%
- invocations/2006.5 µs13% faster
- triggers/1011 µs15% faster
- triggers/5039 µs18% faster
- triggers/200137 µs17% faster
Invocation tracking, concurrent4 benchmarks
The same with N calls at once.
- 1456 ns37% faster
- 83.4 µs40% faster
- 3219 µs29% faster
- 12882 µs29% faster
Invocation tracking2 benchmarks
Recording which worker each call belongs to.
- add_remove_sequential385 ns41% faster
- add_with_200_existing393 ns41% faster
Ws roundtrip payload sizes3 benchmarks
- 1kb59 µsnew
- 10kb80 µsnew
- 100kb334 µsnew
Method
How we measure
Everything on this page comes from one open-source tool that runs the same way on your machine as in CI, and every number links back to the run that produced it.
microVM · KVM
iii engine
N vCPUs, pinned
cgroup cap M, no swap
⇄ loopback
Load generator
6 vCPUs, 2 GiB
SDK workers and callers
- Machine sizes
- Each size is a microVM (libkrun on KVM, the virtualization iii's own sandboxes use). The engine runs pinned to its N virtual CPUs, in a cgroup capped at M with no swap: past it, the kernel stops it, and the run says where. The load generator runs in the same VM on 6 vCPUs and 2 GiB of its own and talks to the engine over the VM's loopback, so it never takes the engine's CPU and nothing proxies their sockets.
- The host
- The same engine as a plain process, pinned to half of the machine’s cores and not capped: the reference without a VM.
- The load
- Real SDK workers (the Rust SDK) call, host, observe and emit, as an application would. Idle workers are raw WebSockets, so ten thousand fit in one process.
- A step
- 10 seconds of load after a warm-up, with the engine’s CPU, memory and threads read from /proc every 250 ms. Every scenario starts on a fresh engine.
- The engine
- As it ships: default configuration, every span recorded, logs at info. It is the Linux glibc build, the one in the Docker image.
- What it is not
- A shared CI machine, so runs differ by several percent: compare lines within a run, and runs by their trend. The numbers are for this machine; a faster CPU moves every line.
Run it yourself
Linux with KVM. The report lands in target/loadtest/.
cargo build --release -p iii -p iii-loadtest target/release/iii-loadtest suite --profiles 2x1024