Performance & scaling¶
This page gives a feel for EDRI's throughput, latency and how it scales — plus a reproducible harness so you can measure your workload on your hardware.
Warning
Indicative numbers — read the shape, not the absolutes. Every figure below
is from a single manual run; absolute throughput varies with hardware, load and
Python version. What transfers is the scaling behavior and where the limits
are. Re-run the benchmarks/ suite to get
numbers for your own setup.
Measurement environment: 24-core Linux box · CPython 3.14 ·
ENVIRONMENT=production · clients over loopback · a single EDRI instance.
What bounds EDRI's performance¶
EDRI trades raw single-endpoint throughput for isolation and explicit concurrency (see Architecture Overview):
The Router, the Broker and each Manager are single-threaded processes. One request crosses several of them.
Events move between processes as pickled objects through
multiprocessingqueues/pipes — every hop is a serialize + copy.Parallelism is explicit: a Manager handles one event at a time; CPU work must be pushed to Workers (threads or processes).
So the levers are: keep per-event work small, parallelize with Workers (processes for CPU), split load across multiple Managers, and keep large payloads off the pickled path (use SharedMemoryPipe in Workers).
For context: a single-process ASGI framework¶
Warning
Not an apples-to-apples framework benchmark. FastAPI
here is a single in-process ASGI app with no distributed routing — a bare
async def handler returning a dict. EDRI's /noop crosses four
processes (Listener → Broker → Router → Manager and back), pickling the
event at every hop, in exchange for the isolation and distributed routing
described above. The gap below is the visible cost of that architecture, not
a claim that EDRI is slow at what it's actually for. Reproduce with
python benchmarks/compare_fastapi.py (needs pip install fastapi,
otherwise not an EDRI dependency).
Same trivial /noop endpoint, same client-side load generator
(http_load), same hardware, two runs:
Concurrency |
EDRI (1 worker) |
FastAPI (1 worker) |
EDRI (4 workers) |
FastAPI (4 workers) |
|---|---|---|---|---|
1 |
~1040–1115 |
~9400–10000 |
~1150–1165 |
~9480–9660 |
8 |
~3050–3390 |
~12200–13400 |
~4080–4130 |
~12700–13260 |
32 |
~3625–3790 |
~12150–12860 |
~4450–4475 |
~13040–13200 |
FastAPI is ~3–9× faster here, and the gap is widest at concurrency 1
(~9×) and narrows toward higher concurrency (~3×): EDRI's fixed per-request
cost is the four-hop, four-process round trip itself, which dominates when
there's little concurrent work to pipeline behind it; FastAPI's is a single
in-process function call with no IPC at all. Neither number moves much with
workers, in either framework, at this concurrency — both are still far
from saturating this box's cores.
The honest takeaway: if the whole job is "answer this HTTP request with no cross-process work," a single-process framework will always win, structurally. EDRI's cost buys the things in the section above — process isolation per Manager, a Worker model for CPU/I/O offload, distributed event routing, a pluggable Broker/Router — that a single in-process handler doesn't need and doesn't provide. Whether that trade is worth ~3–9× on a trivial endpoint depends entirely on whether your application needs what it buys.
HTTP request throughput & latency, WebSocket vs REST¶
A trivial round-trip (handler just sets one response field) over a persistent
WebSocket connection versus REST (/noop), same client machine, three
runs to check stability:
Concurrency |
WebSocket msgs/s |
REST req/s |
|---|---|---|
1 |
~1115–1265 |
~1145–1200 |
8 |
~3350–3535 |
~2915–3100 |
32 |
~3220–3460 |
~3360–3485 |
REST tracks WebSocket closely: a WS client registers once on connect and then
just streams; REST checks a client's return pipe out of a pool, reuses it for
the request, and returns it once the response has drained — so neither path
pays a per-request registration cost. Take the exact ordering with a grain of
salt, though: the two load generators aren't quite apples-to-apples (ws_load
drives all connections from one asyncio event loop; http_load uses a real
OS thread per connection) — the headline is that neither path carries a
per-request registration cost, not that one is architecturally faster than
the other.
For chatty, low-latency interaction (live updates, RPC-style calls) WS still avoids HTTP framing/header overhead per message, so it remains the better fit even though the throughput gap has closed.
gRPC vs WebSocket vs REST round-trip¶
Same trivial round-trip, same Noop event, now also over gRPC —
a unary call (one call per request, like REST) and a persistent bidi
Connect stream (like WebSocket), same client machine, three runs:
Concurrency |
WebSocket msgs/s |
REST req/s |
gRPC unary calls/s |
gRPC bidi msgs/s |
|---|---|---|---|---|
1 |
~1178–1227 |
~1057–1138 |
~959–1021 |
~1106–1215 |
8 |
~2881–3009 |
~2526–2755 |
~2513–2523 |
~2504–2559 |
32 |
~3564–3613 |
~2843–2863 |
~2779–2840 |
~3856–3901 |
The shape matches the WS/REST comparison above, one level down: gRPC unary
tracks REST closely (both check a pipe out of a pool per call, no
per-request registration), and gRPC bidi tracks WebSocket closely (both
register once and then just stream) — with unary/bidi each landing a little
below their REST/WS counterpart. That gap is protobuf encode/decode plus one
extra asyncio hop per call inside GRPCListener (see gRPC's Data
flow), on top of the same four-process round trip every transport pays.
grpc.aio's own client-side overhead is part of that gap too, not isolated
here — this measures the whole call, not just the server side.
Payload size¶
Same endpoint, response body of varying size, ab -c 8:
Response size |
Throughput |
Mean latency |
|---|---|---|
1 KB |
~2635 req/s |
3.0 ms |
100 KB |
~1850 req/s |
4.3 ms |
1 MB |
~340 req/s |
23.6 ms |
Small and medium payloads are cheap; at 1 MB the per-event
serialize-and-copy across the pipeline dominates and throughput drops ~8×
from the 1 KB baseline. For
large or streamed responses, don't return a big pickled field — stream via
File + SharedMemoryPipe in Workers, which moves the bytes
out-of-band with backpressure and keeps them off the broker.
Large concurrent responses don't wedge the API: the broker's Non-blocking outbound send (sender pool) keeps one slow client from blocking the others, so a few concurrent 1 MB responses can't stall the whole pipeline for everyone else.
Core event-routing throughput¶
Independent of HTTP: request → response round-trips through the real Router (each round-trip = two Router dispatches + two pickled pipe hops — a request handler attaches a response and the same Manager's response handler receives it back):
Round-trips |
Throughput |
|---|---|
1 000 |
~14 000–16 000 events/s |
10 000 |
~16 000–18 000 events/s |
50 000 |
~11 000–14 000 events/s |
So the Router sustains on the order of ~12–18k full event round-trips per second here (three runs each; low-n figures carry more relative noise since the whole request completes in well under a second). This is the ceiling the API path sits on top of; the HTTP numbers are lower because each request also pays Listener parsing, the Broker, two extra process hops and JSON serialization.
Worker scaling (CPU-bound)¶
A CPU-bound handler (~128 ms of compute) offloaded to a Worker, ab -c 8, while
varying the worker type and max_workers:
Worker type |
|
Throughput |
|---|---|---|
|
1 |
~6.9 req/s |
|
8 |
~7.0 req/s |
|
1 |
~7.0 req/s |
|
8 |
~36 req/s |
The headline: threads do not scale CPU-bound work — the GIL serializes them,
so max_workers=8 is no faster than 1. Processes do — ~5.2× here with
8 workers (not a full 8× because each task pays process-spawn overhead, and the
box was otherwise busy). Use WorkerProcess for CPU-bound work and size
max_workers to your cores; use WorkerThread for I/O-bound work (no GIL
contention while blocked, much cheaper than a process). See
Worker.
Manager scaling¶
The same workload — a handler that burns ~2 ms of CPU inline (so the single-threaded Manager is the bottleneck) — spread across N independent Managers, each its own event type, driven at total concurrency 32:
Managers |
Aggregate throughput |
p95 |
|---|---|---|
1 |
~450 req/s |
106 ms |
2 |
~730 req/s |
68 ms |
4 |
~990 req/s |
45 ms |
A single Manager caps at ~450 req/s (≈ 1 / 2 ms, serial), and adding Managers scales aggregate throughput (~2.2× from 1→4) while cutting tail latency. It is not perfectly linear — the shared Listener/Broker/Router pipeline is also in the path — but this is the concrete payoff of the "split work across more Managers" advice: each Manager is its own process and runs in true parallel.
Multi-worker Listener (uvicorn workers)¶
The Listener normally runs as a single process, so its own CPU-bound work —
HTTP/ASGI parsing, event pickling, JSON encoding — is capped at one core no
matter how much concurrency you throw at it. Set workers via
EDRI_ASGI_OPTIONS (see Environment Variables for details) to run N worker processes
instead, each a spawned OS process sharing the same listening socket and the
same Broker queue (the response-pipe pool that removes per-request
registration overhead — see below — already covers every worker, since it's
per-process state):
EDRI_ASGI_OPTIONS='{"workers": 4}'
WebSocket and REST /noop throughput, 1 worker vs. 4, two runs to check
stability:
Concurrency |
WS: 1 worker |
WS: 4 workers |
REST: 1 worker |
REST: 4 workers |
|---|---|---|---|---|
1 |
~960–1010 |
~1070 |
~1095–1125 |
~1060–1090 |
8 |
~2660–2725 |
~3560–3790 |
~3005–3050 |
~3755–3815 |
32 |
~3035–3090 |
~4220–4240 |
~3660–3765 |
~4560–4675 |
Roughly +25–35% at concurrency 8/32 for both protocols. WebSocket moves too even though a WS connection is pinned to whichever worker accepted it — the gain is from spreading many independent connections' framing/encoding work across cores, not from any one connection getting faster.
Sizing: re-measure with benchmarks/bench.py
on your own hardware and workload before picking a number — the right count
depends on how many cores the Listener's own parsing/encoding work can
actually use before something else in the pipeline (the Broker, still a
single process) becomes the next bottleneck. This is exactly the kind of
number the warning at the top of this page is about.
Why the response-pipe pool matters here¶
Registering a client with the Broker — a Register round-trip, socket
setup, and an fd hand-off to pass the return pipe across the process
boundary — is real multiprocessing connection/socket work, not free. The
Broker is a single process regardless of how many Listener workers there
are, so without pooling, a multi-worker Listener would just mean more
requests paying that per-request registration cost against the same single
process — capping the gains above well below what multi-worker alone could
otherwise deliver.
The response-pipe pool avoids it: register a client once, hand its pipe back to the pool once the response has been drained, and reuse it for the next request instead of paying the registration cost again. That's why multi-worker and the pool compound rather than either one hitting a ceiling alone — multi-worker parallelizes the Listener's own CPU work across cores, pooling keeps the Broker doing only real request-routing work.
Trade-offs:
Requests are load-balanced across workers by the kernel accepting on a shared socket (no
SO_REUSEPORTtuning), which is not perfectly even under low concurrency — fine in aggregate, worth knowing if you see per-worker vitals differ.Middleware instances must be picklable (they get sent to each spawned worker along with the rest of the app) — the same constraint any
multiprocessing-shared object already has elsewhere in EDRI.This only applies to the API edge. The Broker, Router, and each Manager are still single processes — combine with Manager scaling (above) to raise the ceiling further down the pipeline too.
Middleware overhead¶
Throughput of /noop (concurrency 16) with a chain of N pass-through
middlewares (each runs on both request and response):
Middlewares |
Throughput |
p50 |
|---|---|---|
0 |
~3940 req/s |
3.8 ms |
1 |
~3530 req/s |
4.0 ms |
4 |
~3235 req/s |
4.4 ms |
8 |
~2735 req/s |
5.2 ms |
A small, roughly linear cost per middleware — p50 moves from 3.8 ms to 5.2 ms across 8 middlewares, ~0.18 ms each. Still small in absolute terms: layer middlewares (auth, logging, timing, …) reasonably freely; the cost that matters is whatever work your middleware does, not the chain mechanism itself.
Serialization cost¶
Every cross-process hop pickles the event. Round-trip pickle.dumps +
pickle.loads of an event by payload size:
Payload |
dumps+loads |
|---|---|
empty |
~5 µs |
1 KB |
~5 µs |
100 KB |
~12 µs |
1 MB |
~290 µs |
Pickle itself is cheap; it is the number of hops × payload that adds up (an
API request is pickled ~4 times end to end). This is the concrete reason large
payloads belong on SharedMemoryPipe rather than in a response field.
Free-threaded CPython (no-GIL)¶
CPython 3.13+ offers a free-threaded build (3.14t; install with
uv python install 3.14t or an official free-threaded installer). The full
test suite, including the Switch integration tests (multiprocessing,
subprocess, TCP, pickle), passes on it unchanged.
posix_ipc (used by SharedMemoryPipe, see SharedMemoryPipe in Workers) has not
declared free-threading support, so importing it re-enables the GIL for the
whole process — silently, with only a RuntimeWarning. A plain routing app
stays GIL-free; any app that uses File/SharedMemoryPipe loses
free-threading process-wide the moment that module loads.
EDRI's own architecture is multiprocessing end to end — each Manager is a
separate process — so removing the GIL does not by itself speed anything up;
no CPU-bound Python currently runs on threads inside one process.
Free-threading is an enabler, not a free win. It is not covered by CI: the
test matrix runs 3.12/3.13/3.14/3.15-rc, no t variant.
CPython experimental JIT¶
CPython 3.13+ ships an experimental copy-and-patch JIT, enabled at runtime with
PYTHON_JIT=1 (the interpreter must be built with the JIT included — the
official 3.14 builds are, disabled by default). The full test suite passes with
it enabled.
It does not speed up EDRI's own pipeline: routing, pickling and JSON encoding
are attribute- and I/O-bound, not the tight numeric loops the JIT targets. On
the managers benchmark (handler burns CPU inline) it lifts aggregate
throughput ~10% and trims p95; on the ws/REST path it stays within noise. So
it only helps when Manager handlers do heavy CPU-bound Python — measure your own
workload before enabling it in production.
Scaling guidance¶
Symptom |
Lever |
|---|---|
A single Manager saturates (throughput ≈ 1 / handler time, latency climbing) |
Split handlers across more Managers; run multiple app instances behind a load balancer |
Listener itself is the bottleneck (HTTP parsing/pickling pegs one core) |
Multi-worker Listener: |
CPU-bound handler is slow / serializes |
|
Many blocking I/O calls in a handler |
|
Large responses / downloads |
|
Slow clients stalling the API |
Already handled by the broker Non-blocking outbound send (sender pool); size |
Need to see where time goes per request |
A response Middleware that publishes |
Reproducing these measurements¶
Everything above comes from the committed suite under benchmarks/:
benchmarks/app.py— an EDRI server exposing the endpoints used here (trivial handler, sized payload, CPU worker, Manager↔Manager round-trip counter, busy per-manager endpoints, a WebSocket round-trip, a deliberately slow consumer), all shaped by environment variables (BENCH_WORKER,BENCH_MAXW,BENCH_MANAGERS,BENCH_MIDDLEWARES,BENCH_GRPC,BENCH_SLOW, …).benchmarks/bench.py— a self-contained runner (stdlib threads for HTTP, thewebsocketslibrary for WS,grpc.aiofor gRPC — no external load tool) that launches the server in the right shape per scenario and prints the tables above.benchmarks/fastapi_app.py+benchmarks/compare_fastapi.py— the single-process reference point above. Needspip install fastapiseparately; not an EDRI dependency.
python benchmarks/bench.py # all scenarios
python benchmarks/bench.py ws # one: ws | managers | middleware | grpc | slow
python benchmarks/bench.py grpc # gRPC vs WebSocket vs REST (needs the grpc extra)
python benchmarks/bench.py slow # latency under a consumer that won't drain
python benchmarks/compare_fastapi.py # EDRI vs FastAPI reference point
The grpc scenario needs pip install edri[grpc] (or grpcio/grpcio-reflection
directly) — not installed by default, the same way fastapi isn't for the
comparison above.
The HTTP-throughput, payload, core-routing and worker-scaling numbers in the
earlier sections were taken the same way; drive benchmarks/app.py directly if
you want to vary those by hand:
ENVIRONMENT=production python benchmarks/app.py # then, against it:
curl http://127.0.0.1:8080/route/10000 # core events/s
# worker scaling: restart with BENCH_WORKER=thread|process, BENCH_MAXW=1|8