# 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. ```{benched} ../../benchmarks/results --- view: trend metric: median benchmark-filter: '*test_private_patch_cost*' x-axis: time --- ``` 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: ```sh 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. ```{benched} ../../benchmarks/results --- view: trend metric: median benchmark-filter: test_fanout[[]*[]] x-axis: time --- ``` The [full interactive report](https://1kbgz.github.io/transports/benchmarks/) includes all committed timing runs and filters.