Benchmark: Rust port against the C server
The C server (janus-gateway at commit bc0de3b3) and this port, built and run the same way, in Docker, with the same CPU and memory limits, the same config and the same load generator. CPU time and memory come from each container's own cgroup counters, not from top. Every number below is the median of 3 runs, with the C and Rust runs interleaved; the whisker on each bar is the lowest to the highest run.
Read this before you quote a number
One machine, one afternoon: Apple M5 Pro under Docker Desktop (arm64 Linux VM), not a production x86 server, and the load generator is Node. Treat the ratios as the finding and the absolute numbers as this machine's. The workloads are synthetic: HTTP and WebSocket control traffic, and EchoTest media from a loopback WebRTC client. They say nothing about VideoRoom fan-out, AudioBridge mixing or simulcast, which the port does not run end to end yet (status).
Scorecard: who is better at what
Ratios compare like with like; "2.00×" means twice as good. A result within 5% is a tie.
| Aspect | C | Rust | Better |
|---|---|---|---|
| Start-up time (to first answer) | 159.0 ms | 152.0 ms | tie (within 5%) |
| Idle memory (anonymous RSS) | 26.2 MiB | 7.9 MiB | Rust 3.32× |
| Idle CPU | 1.19 % of one core | 1.99 % of one core | C 1.66× |
| Throughput: GET /info, 32 conn | 29,008 req/s | 37,863 req/s | Rust 1.31× |
| Throughput: create + destroy session, 32 conn | 39,680 req/s | 92,007 req/s | Rust 2.32× |
| Throughput: attach + detach handle, 32 conn | 30,506 req/s | 80,393 req/s | Rust 2.64× |
| Throughput: plugin request (VideoRoom list), 32 conn | 33,999 req/s | 48,635 req/s | Rust 1.43× |
| Throughput: async message + event (EchoTest), 32 conn | 18,902 req/s | 30,812 req/s | Rust 1.63× |
| Throughput: WebSocket plugin request, 32 conn | 71,428 req/s | 57,858 req/s | C 1.23× |
| p99 latency: GET /info, 32 conn | 1.80 ms | 3.60 ms | C 2.00× |
| p99 latency: plugin request (VideoRoom list), 32 conn | 1.79 ms | 2.88 ms | C 1.61× |
| p99 latency: WebSocket plugin request, 32 conn | 0.85 ms | 3.02 ms | C 3.55× |
| CPU per request: GET /info, 32 conn | 65.6 µs | 66.9 µs | tie (within 5%) |
| CPU per request: create + destroy session, 32 conn | 33.8 µs | 36.7 µs | C 1.09× |
| CPU per request: plugin request (VideoRoom list), 32 conn | 50.9 µs | 53.3 µs | tie (within 5%) |
| CPU per request: WebSocket plugin request, 32 conn | 33.5 µs | 41.1 µs | C 1.23× |
| Memory per 1,000 sessions (each with a VideoRoom handle) | 12.05 MiB | 2.24 MiB | Rust 5.38× |
| Memory still held after destroying every session | 42.4 MiB above idle | 23.0 MiB above idle | Rust 1.84× |
| Peak memory of the scale test | 394.4 MiB | 36.9 MiB | Rust 10.69× |
| Media: CPU per peer-second, 300 EchoTest peers | 5.34 ms CPU | 2.93 ms CPU | Rust 1.82× |
| Media: memory at 300 peers | 123 MiB | 89 MiB | Rust 1.39× |
Tally: Rust better on 11 of 21 aspects, C better on 7, the rest tied.
What stands out
- Memory is Rust's clearest win. Idle, the Rust port holds 7.9 MiB against 26.2 MiB; each 1,000 live sessions cost it 2.2 MiB against 12.1 MiB, and the C server's peak during the scale test was 394 MiB against 37 MiB.
- Session and handle churn: Rust is 2.3× and 2.6× faster, by using more cores, not by being cheaper per request. Creating and destroying sessions costs about 34 µs of CPU in C and 37 µs in Rust, but C used 1.3 cores to do it and Rust 3.4. The C core processes incoming API messages on a single thread (the comment on
task_pool_sizeinjanus.jcfg.samplesays so), which caps the CPU it can use for this work; the Rust port spreads it over its async runtime. - CPU per request is close to a draw on the simple paths (GET /info 66 against 67 µs, VideoRoom list 51 against 53 µs). Over WebSockets C is cheaper (34 against 41 µs) and answers faster.
- C has the better tail. At 32 connections the 99th percentile of GET /info is 1.8 ms in C and 3.6 ms in Rust, and over WebSockets 0.85 against 3.02 ms. The Rust HTTP path keeps the core and the long-poll queues behind one mutex (
janus/src/http_listener.rs); contention on it is a plausible cause, but this benchmark did not measure it. - Idle CPU is higher in the Rust port (1.99% of a core against 1.19%). Small, but it is the one resource where C is quietly cheaper when nothing happens. The core's 100 ms timer thread and the async runtime's wake-ups are candidates; not investigated.
- Media plane, 600 EchoTest peers: C used 2.08 cores and 391 MiB, Rust 0.67 cores and 153 MiB; connected 600 and 527 of 600, loss 57.56% and 0.00%.
How it was measured
| Machine | Apple M5 Pro, macOS 26.6.2 |
| Docker | 29.9.2; VM: 18 CPUs, 33596157952 bytes, 7.0.14-linuxkit, linux/aarch64 |
| C server | image janus-c:bc0de3b3 (sha256:6154ac8e3ba5): the pinned oracle, -O2 autotools build, GLib, libnice, libmicrohttpd, libwebsockets, OpenSSL |
| Rust port | janus-rs:bench (sha256:a97a6f6cac12): cargo build --release (thin LTO, one codegen unit) of commit c5426c1, same plugin set as the C image |
| Limits, each server | 4 CPUs (pinned to cores 0-3), 4g memory, no swap, logging off |
| Load generator | Node 22 in a container pinned to cores 8-15, on the same Docker network, 8 worker threads, keep-alive HTTP |
| Config | identical files for both (bench/conf): HTTP and WebSockets with the Admin API, VideoRoom room 1234, debug_level = 2, session_timeout = 0 |
| Protocol | each scenario: 2 s warm-up, 10 s measured, at the listed connection counts; 3 runs, C and Rust alternated; the server is restarted between scenarios' groups, and each scale test starts on a fresh server |
| Measuring | CPU = usage_usec of the server's cgroup; memory = anon from memory.stat (resident heap and stacks, not page cache) and memory.peak; latency = per request, from a 150-buckets-per-octave histogram |
| Errors | every response is checked; a request with an unexpected answer is counted as an error, and the tables show 0 for all runs unless stated |
Control plane: HTTP and WebSocket API
The Janus API with no media: sessions, handles, a synchronous plugin request, an asynchronous one with its event, and the same over WebSockets. Concurrency is the number of connections, each sending its next request when the last answer arrives.
Throughput
Latency
| Scenario | Conn | C req/s | Rust req/s | C p50 / p99 (ms) | Rust p50 / p99 (ms) | Faster |
|---|---|---|---|---|---|---|
| GET /info | 1 | 7,350 | 9,310 | 0.13 / 0.22 | 0.10 / 0.20 | Rust 1.27× |
| GET /info | 32 | 29,008 | 37,863 | 1.06 / 1.80 | 0.79 / 3.60 | Rust 1.31× |
| GET /info | 256 | 28,889 | 33,567 | 8.78 / 12.36 | 7.89 / 37.12 | Rust 1.16× |
| create + destroy session | 32 | 39,680 | 92,007 | 0.77 / 1.43 | 0.32 / 0.92 | Rust 2.32× |
| create + destroy session | 256 | 40,587 | 104,864 | 6.21 / 9.32 | 2.26 / 6.09 | Rust 2.58× |
| attach + detach handle | 32 | 30,506 | 80,393 | 1.00 / 1.83 | 0.34 / 1.28 | Rust 2.64× |
| attach + detach handle | 256 | 30,542 | 77,549 | 8.31 / 11.64 | 0.43 / 19.89 | Rust 2.54× |
| plugin request (VideoRoom list) | 1 | 7,778 | 8,040 | 0.12 / 0.22 | 0.12 / 0.21 | tie (within 5%) |
| plugin request (VideoRoom list) | 32 | 33,999 | 48,635 | 0.89 / 1.79 | 0.60 / 2.88 | Rust 1.43× |
| plugin request (VideoRoom list) | 256 | 31,198 | 40,755 | 8.08 / 11.43 | 6.50 / 32.02 | Rust 1.31× |
| async message + event (EchoTest) | 32 | 18,902 | 30,812 | 1.64 / 2.84 | 0.90 / 3.12 | Rust 1.63× |
| WebSocket plugin request | 32 | 71,428 | 57,858 | 0.42 / 0.85 | 0.15 / 3.02 | C 1.23× |
| WebSocket plugin request | 256 | 79,805 | 55,784 | 3.01 / 5.74 | 0.15 / 27.87 | C 1.43× |
Errors across all control-plane runs: 0.
CPU audit
CPU is what the server's cgroup spent, so it includes every thread of the process and the kernel's network work done on its behalf. "Cores" is CPU seconds per wall second; the server was capped at 4.
CPU per request
Cores in use
| Scenario | Conn | C cores | Rust cores | C µs/req | Rust µs/req | C kernel share | Rust kernel share | Load-gen cores |
|---|---|---|---|---|---|---|---|---|
| GET /info | 1 | 0.59 | 0.52 | 80.4 | 55.9 | 43% | 57% | 0.4 / 0.4 |
| GET /info | 32 | 1.90 | 2.54 | 65.6 | 66.9 | 39% | 50% | 2.5 / 2.5 |
| GET /info | 256 | 1.82 | 2.51 | 63.0 | 74.7 | 41% | 50% | 1.8 / 2.3 |
| create + destroy session | 32 | 1.34 | 3.36 | 33.8 | 36.7 | 65% | 52% | 2.8 / 5.1 |
| create + destroy session | 256 | 1.30 | 3.55 | 31.9 | 33.7 | 65% | 52% | 2.1 / 5.4 |
| attach + detach handle | 32 | 2.33 | 3.37 | 76.5 | 41.8 | 63% | 49% | 2.6 / 4.7 |
| attach + detach handle | 256 | 2.25 | 3.25 | 73.1 | 42.5 | 61% | 48% | 2.0 / 4.3 |
| plugin request (VideoRoom list) | 1 | 0.51 | 0.59 | 65.0 | 73.8 | 60% | 49% | 0.4 / 0.4 |
| plugin request (VideoRoom list) | 32 | 1.73 | 2.59 | 50.9 | 53.3 | 59% | 47% | 2.5 / 2.6 |
| plugin request (VideoRoom list) | 256 | 1.62 | 2.59 | 51.8 | 63.0 | 59% | 46% | 1.8 / 2.6 |
| async message + event (EchoTest) | 32 | 1.61 | 3.07 | 85.3 | 99.8 | 58% | 50% | 2.4 / 3.8 |
| WebSocket plugin request | 32 | 2.38 | 2.38 | 33.5 | 41.1 | 51% | 51% | 2.0 / 2.2 |
| WebSocket plugin request | 256 | 2.37 | 2.40 | 29.7 | 43.1 | 45% | 52% | 1.5 / 2.3 |
The load generator never used more than 7 of its 8 cores, so the client was not the limit in any run.
Idle CPU, measured for 10 s after start-up with no clients:
| C | Rust | |
|---|---|---|
| CPU, % of one core | 1.19 | 1.99 |
RAM audit
Memory is the anonymous resident memory of the container (heap, stacks, and anything else the process allocated), read from memory.stat. File cache is excluded so the C server's mapped plugin libraries do not count against it.
Memory per session
| Live sessions | C (MiB) | Rust (MiB) |
|---|---|---|
| 0 | 24.4 | 7.7 |
| 2,000 | 52.2 | 13.7 |
| 4,000 | 75.8 | 18.2 |
| 6,000 | 96.9 | 20.3 |
| 8,000 | 122.1 | 26.5 |
| 10,000 | 145.0 | 30.1 |
| after destroying all of them | 66.8 | 30.7 |
| peak during the test | 394.4 | 36.9 |
From idle to 10,000 sessions the C server grew by 120.5 MiB (12.05 MiB per 1,000 sessions) and the Rust port by 22.4 MiB (2.24 MiB per 1,000). After every session was destroyed, C still held 42.4 MiB above its idle level and Rust 23.0 MiB.
Memory after each workload
Both servers keep what they allocated during earlier scenarios, so this chart shows allocator behaviour as much as live data: it climbs through the scenarios in order. The idle row is the baseline.
| C | Rust | |
|---|---|---|
| Idle (MiB) | 26.2 | 7.9 |
| Start-up to first answer (ms) | 159 | 152 |
Media plane: WebRTC peers through EchoTest
Each peer is a loopback WebRTC client (parity/examples/media_load.rs, the same client that xtask parity-media uses) that runs ICE, a DTLS-SRTP handshake, and sends audio and video RTP at 50 packets per second each for 30 s. EchoTest sends everything back. The client runs in a container that shares the server's network namespace, on other cores. The server is restarted before each level so every row starts cold. CPU is read over the steady part of the run (when all peers are connected).
| Peers | Server | Connected | Packet loss | CPU cores (steady) | CPU ms per peer-second | Kernel share | Memory peak (MiB) | Connect p50 / max (ms) | Client cores used (of 8) |
|---|---|---|---|---|---|---|---|---|---|
| 50 | C | 50 / 50 | 0.00% | 0.35 | 7.15 | 39% | 47 | 24 / 42 | 0.4 |
| 50 | Rust | 50 / 50 | 0.00% | 0.29 | 6.44 | 50% | 23 | 37 / 55 | 0.5 |
| 150 | C | 150 / 150 | 0.00% | 0.91 | 6.05 | 46% | 80 | 24 / 41 | 0.8 |
| 150 | Rust | 150 / 150 | 0.00% | 0.53 | 3.86 | 55% | 50 | 34 / 60 | 0.6 |
| 300 | C | 300 / 300 | 0.00% | 1.47 | 5.34 | 50% | 123 | 24 / 40 | 0.9 |
| 300 | Rust | 300 / 300 | 0.00% | 0.73 | 2.93 | 55% | 89 | 34 / 60 | 0.8 |
| 600 | C | 600 / 600 | 57.56% | 2.08 | 4.20 | 36% | 391 | 24 / 45 | 1.2 |
| 600 | Rust | 527 / 600 | 0.00% | 0.67 | 2.78 | 53% | 153 | 37 / 389 | 1.2 |
What the failures at the top level are. At the highest peer count the two servers fail in different ways, and neither is the client running out of CPU (the load generator used about 1.2 of its 8 cores):
- C connected every peer, then lost more than half of the echoed packets while using only about 2 of its 4 cores. CPU is not what limited it; packets were dropped before the plugin could echo them (a full socket buffer or a saturated relay thread; this benchmark did not look further).
- Rust echoed every packet of the peers that connected (0% loss, about 0.7 cores), but 70 to 80 of the 600 peers never connected: the client's HTTP request that carries the SDP offer (
POST /janus/<session>/<handle>) got no answer within its 10 s timeout (Resource temporarily unavailable, seen again in a separate run). While hundreds of peers do ICE and DTLS at once, the Rust server's API path stops answering some requests, and connect times reach 0.4 s at the tail against 40 ms for C. This is the same shape as the control-plane tail latency above, now under media load.
What the numbers mean
The scorecard above is the summary. Read the charts for where each server spends its resources:
- CPU per request says how much headroom is left at a given load. Lower is cheaper to run.
- Cores in use at saturation says whether a server hit its four-core cap. A server that stays under the cap while the load generator is the limit has headroom this test did not reveal.
- Memory per session and memory after destroying everything say how a server behaves over weeks: growth that is not given back is what a long-running deployment feels.
- Latency percentiles matter more than the mean: a tail that grows with concurrency is what clients feel as stalls.
Limits of this benchmark
- One machine and one Docker VM on Apple silicon (arm64). x86 servers differ in SIMD and in how each library is optimised, so repeat it on the hardware you deploy on.
- The load generator is Node.js. It is the same for both servers and is checked for saturation above, but it is not an optimised C or Rust load tool.
- The control-plane workloads are small JSON requests with no media. They stress the dispatcher, the session map, the HTTP stack and the plugin message path.
- Media is EchoTest only, with a client that does not model a browser's congestion control, NACK or simulcast. It exercises ICE, DTLS, SRTP, RTP and RTCP, and the relay path of one plugin, not a conference.
- Logging is off (
debug_level = 2, Docker log drivernone); a verbose server will spend more on both sides. - The C image is the pinned oracle build, not a distribution package tuned for production. The Rust image is a plain
cargo build --release; neither got profile-guided optimisation. - Nothing here measures correctness. A server that answers faster but wrongly would score well; the parity harness and the Bench are where correctness is checked.
Reproduce it
docker build -f bench/Dockerfile.rust -t janus-rs:bench . # the Rust server, release
docker build -f bench/Dockerfile.load -t janus-bench-load bench # the load generator
docker build -f bench/Dockerfile.media -t janus-bench-media . # the media client
node bench/run.mjs # control plane, writes bench/results/latest.json
node bench/run-media.mjs # media plane, writes bench/results/media.jsonThe image janus-c:bc0de3b3 comes from parity/oracle/Dockerfile.oracle (Build and run). Copy the two result files to docs-site/data/benchmark-control.json and benchmark-media.json and run node docs-site/scripts/gen-benchmark.mjs to rebuild this page.