Server fan-out benchmark results

The recorded baseline completes a full 50-update broadcast round to 250 clients × 20 streams in about 160 ms at a third of one CPU core, with delivery p99 under 40 ms. Fan-out coalesces. Under load, undelivered operations for each model compose into one frame and the drain cadence self-clocks to send capacity. Every client still applies each queued operation and converges on the final revision of every stream.

Latency and completion time

WebSocket clients

Streams per client

Active subscriptions

Fleet completion median

Delivery p50

Delivery p95

Delivery p99

Delivery max

10

1

10

115 ms

1.4 ms

2.6–2.9 ms

9.0–9.5 ms

9.0–9.5 ms

100

10

1,000

159–162 ms

4.7–5.6 ms

12.5–17 ms

15.7–33 ms

16.5–36 ms

250

20

5,000

159–168 ms

16.5–17 ms

29.6–32 ms

37–40 ms

39–41 ms

Ranges span two clean runs. Fleet completion measures the full round: publish 50 updates to every stream at 2 ms intervals, then wait until every client has received the final revision of every stream. Delivery latency measures individual patches from the server timestamp to receipt by a client. One delivered patch can compose operations from several revisions.

Server resource use

WebSocket clients

CPU

RSS

Event-loop lag p99

Revisions delivered

10

2.7–3.9%

67.0–67.1 MB

0.5–0.7 ms

22.5–23.0%

100

28.1%

74.8–74.9 MB

4.4–5.9 ms

15.5–17.0%

250

31.5–31.9%

86.1–86.2 MB

17.9–20.1 ms

5.0%

CPU is process CPU use, where 100% represents one fully occupied logical core. Revisions delivered below 100% is frame coalescing at work rather than data loss. transports composes a model’s undelivered operations into one patch, and the autosync drain cadence stretches to match send capacity (interval is the floor, max_interval the ceiling). Every client still applies the queued changes and reaches the final revision of every stream, which the completion times above confirm. After writers have had a drain cycle, max_queue bounds queued frames per connection and max_queue_bytes bounds their encoded payload. One ready-to-send flush can transiently exceed those thresholds; a slow client cannot retain that backlog into the next cycle.

Session budgets

The session-cost benchmark (benchmarks/test_session_cost.py) measures what a connected-but-idle session costs the server, and both benchmarks derive the budget metrics below. Measured on the recording machine at 1,000 clients × 10 streams:

Budget

Target

Measured

RSS per idle session

< 100 KB

73.7 KB

Idle server CPU, whole fleet connected

< 5%

0.5%

CPU per state fan-out to 1k subscribers

< 1 ms

0.16–0.19 ms

Thread growth across 1,000 connects

0

0

Thread flatness is asserted on every run: a session must never cost a thread. The absolute budgets are opt-in (TRANSPORTS_BUDGETS=1) because hosted CI runners are too noisy for stable thresholds; run them locally or on the recording machine. The fan-out CPU budget applies only at 250+ sessions (TRANSPORTS_BUDGET_FANOUT_MIN_SESSIONS) where fixed per-publish cost amortizes; with coalescing it passes with roughly 5× headroom.

Event loop comparison

The harness can swap the server’s event loop (TRANSPORTS_BENCH_LOOP=asyncio|uvloop|rsloop; the client fleet always stays on asyncio so only the measured process varies). At 1,000 clients × 10 streams on the recording machine, ranges over two runs each:

Loop

Fleet completion

Delivery p50

Delivery p99

CPU per 1k-subscriber fan-out

RSS per idle session

asyncio

339–345 ms

63–68 ms

184–192 ms

0.18–0.19 ms

73.7 KB

uvloop

264–274 ms

83–92 ms

128–137 ms

0.18–0.19 ms

73.2 KB

rsloop

215–216 ms

80–83 ms

219–224 ms

0.21 ms

124 KB

With coalescing in place the loops converge on CPU. uvloop has the best delivery p99. rsloop has the fastest fleet completion but about 15% more fan-out CPU and about 70% more memory per idle session, which puts it over the session budget. asyncio remains the default. uvloop is a reasonable choice where tail latency matters most. Re-measure on the deployment platform, since these standings shift between macOS and Linux.

Permessage-deflate

WebSocket permessage-deflate compresses each frame per connection, so encode-once sharing stops at the compression extension, and its zlib context dominates idle-session memory. At 1,000 × 10 with deflate disabled on the server (TRANSPORTS_BENCH_WS_DEFLATE=0, i.e. uvicorn’s ws_per_message_deflate=False):

Configuration

Fleet completion

Delivery p50

Delivery p99

CPU per 1k-subscriber fan-out

RSS per idle session

deflate on

339–345 ms

63–68 ms

184–192 ms

0.18–0.19 ms

73.7 KB

deflate off

260 ms

51 ms

110 ms

0.15 ms

26.8 KB

Disabling deflate improves every measured axis on loopback and cuts idle memory per session 2.7×. It is the most effective single configuration change for a high-fan-out deployment where bandwidth is cheaper than CPU and RAM. Keep it enabled where the network is the constraint.

Multi-tenant hub

The hub benchmark (benchmarks/test_hub_fanout.py) measures the two-tier Hub path in the csp-gateway shape: T tenants, one connection each, P private models per tenant (its own session) plus S shared models every tenant subscribes to. TRANSPORTS_BENCH_HUB=tenants:private:shared sets the scale and TRANSPORTS_BENCH_HUB_MODE selects the published class. At 1,000 tenants × 2 private × 5 shared on the recording machine:

Published class

Fleet completion

Delivery p50

Delivery p99

CPU per 1k-subscriber fan-out

Server CPU

RSS growth per tenant

shared only

272 ms

64 ms

105 ms

0.57 ms

48%

24 KB

private only

808 ms

40 ms

114 ms

(7.2 ms)

85%

201 KB

mixed

883 ms

71 ms

183 ms

2.0 ms

80%

112 KB

The shared (broadcast) tier meets the fan-out budget. The private tier’s parenthesized number is not a like-for-like budget comparison: each private patch has exactly one subscriber, so the cost is per unique patch (about 36 µs each for per-tenant drain, diff, encode, and send; nothing can share encoded bytes across tenants). Bringing that down is the next optimization target. Two hub-path fixes landed with this benchmark: the per-tenant flush no longer rescans the connection map per tenant (it was O(tenants²)), and shared-model writes encode once per codec instead of once per connection. Together they are worth about 24% CPU on the shared tier and 12% on the private tier at this scale.

Private-tier memory grows with each tenant’s bounded resume logs (512 patches per model by default); Session(log_cap=...) tunes the retention/memory trade for large-tenant deployments.

Private patch cost

benchmarks/test_private_patch_cost.py measures synchronous Hub.flush() after changing one integer field in each tenant’s private model. It excludes sockets, client processing, and mutation setup. The local 2026-10-15 development run recorded a median 7.5 ms for 1,000 tenants. This is not comparable to the network fan-out latency or a stable CI timing threshold.

A separate cProfile run spent 100 ms of 171 ms of cumulative Hub flush time in Session draining; the native Store mutation calls accounted for 7 ms across 11,000 updates. Python model conversion and JSON encode/decode remain material costs. These measurements do not justify replacing the connection stack or moving tenant orchestration into Rust.

Reproduce the profile separately from timing measurements:

python -m cProfile -o private-patch.prof -m pytest benchmarks/test_private_patch_cost.py
python -m pstats private-patch.prof

The run records retain machine, revision, dirty-checkout, and local-dependency labels. Collect idle, repeatable target-deployment baselines before enforcing absolute latency budgets.

Recorded workload

These ranges come from two clean runs recorded on August 27, 2026:

  • transports at commit fa8d8e9 (0.8.2 plus the coalescing fan-out fix)

  • one server process and all clients on the same machine over loopback

  • Apple silicon (arm64, 18 logical CPUs, 64 GiB RAM), macOS 25.5

  • CPython 3.12.13, JSON codec, unbatched frames, permessage-deflate on, asyncio loop

  • 50 updates per stream per round, published every 2 ms

  • one warm-up round followed by three measured rounds

This is a single-machine fan-out baseline, not a capacity limit. It excludes network latency and does not predict cross-region or multi-host performance. Fleet completion also includes the approximately 100 ms update-publishing interval, so it is broader than patch delivery latency.

An earlier recorded baseline (and the loop comparison it fed) was measured against a stale installed 0.8.0 wheel rather than the repository code and has been replaced; treat any numbers predating this page’s tables as unreliable.

Interactive results

The report below is generated from committed benchmark records. It exposes the recorded runs, benchmark parameters, timing distributions, and machine metadata.

The full interactive report includes all committed timing runs and filters.