These benchmarks are done on a MacBook Pro M1 MAX, although this is irrelevant because the ratio between the numbers is more important than the numbers itself.
The benchmarks were done on Python 3.13 with the latest framework versions at the time (Falcon 4.3.1, FastAPI 0.138.0, Starlette 1.3.1, Robyn 0.87.0, Uvicorn 0.49.0, Granian 2.7.6). Robyn now installs cleanly under Python 3.13.
Fairness here means an equal resource budget, not an identical command line: every framework gets 9 OS processes (≈ one per core), then the best configuration within that budget. Most use --workers=9; Robyn uses --processes=9, because its --workers flag spawns GIL-sharing threads inside a single process — benchmarking it that way makes it look several times slower than it is.
Within that budget, each server's thread pool and each endpoint's concurrency are tuned per workload — e.g. Granian uses one blocking thread per worker for CPU work (avoiding GIL contention) but a large pool for blocking I/O, and ping is driven harder than the CPU test because the fast servers need more connections to saturate. The exact flags and connection counts live in run_bench.py, and each results table notes its own concurrency.
The choice of server matters as much as the framework. Falcon's threaded path is a WSGI app and Uvicorn only speaks ASGI, so Falcon's threaded setup is served by Granian, a fast Rust server, while every async app runs under Uvicorn.
A handler that just returns a tiny response — this measures how fast the stack can accept a request and write a reply, with no real work in the handler. Run at -c 500 so the fast servers are actually saturated.
| Framework | Server | I/O | Req/s | Latency |
|---|---|---|---|---|
| Falcon | Granian | threaded | 120.40k | 4.15ms |
| Uvicorn | async | 142.34k | 3.51ms | |
| Robyn | Robyn | threaded | 143.51k | 3.48ms |
| async | 76.92k | 6.50ms | ||
| FastAPI | Uvicorn | threaded | 54.49k | 9.17ms |
| async | 121.86k | 4.10ms | ||
| Starlette | Uvicorn | threaded | 64.07k | 7.80ms |
| async | 146.15k | 3.42ms |
Each handler generates 100 UUIDs, a small CPU-bound task. Run at -c 100: the work pins all 9 cores at that point, so more connections add only latency, not throughput.
| Framework | Server | I/O | Req/s | Latency |
|---|---|---|---|---|
| Falcon | Granian | threaded | 11.23k | 8.91ms |
| Uvicorn | async | 14.01k | 7.13ms | |
| Robyn | Robyn | threaded | 11.98k | 8.34ms |
| async | 12.99k | 7.70ms | ||
| FastAPI | Uvicorn | threaded | 5.39k | 18.54ms |
| async | 14.06k | 7.11ms | ||
| Starlette | Uvicorn | threaded | 5.48k | 18.22ms |
| async | 14.07k | 7.11ms |
Each request does three sequential sleeps of a random 8–10 ms (~27 ms total), simulating a handler that awaits external I/O such as database or network calls. This is the only test that rewards async, and it is run at high concurrency (-c 2000, far above the other tests) to expose the difference: a sync handler ties up a server thread for the whole ~27 ms, so sync throughput is capped by the size of the server's thread pool, while an async handler yields the event loop and lets all 2000 requests overlap (ceiling ≈ 2000 / 29 ms ≈ 70k req/s on this machine).
| Framework | Server | I/O | Req/s | Latency |
|---|---|---|---|---|
| Falcon | Granian | threaded | 16.51k | 62.31ms |
| Uvicorn | async | 68.13k | 28.84ms | |
| Robyn | Robyn | threaded | 11.51k | 174.16ms |
| async | 53.47k | 36.88ms | ||
| FastAPI | Uvicorn | threaded | 12.23k | 163.95ms |
| async | 63.36k | 31.02ms | ||
| Starlette | Uvicorn | threaded | 11.96k | 167.68ms |
| async | 70.37k | 28.41ms |
Every async setup reaches ~53–70k at ~29–37 ms latency — close to the ideal ~27 ms, meaning the event loop holds all 2000 requests with almost no queuing. The sync setups, all on comparable ~40-thread/process pools, cluster around ~12–16k with latency blown out to ~165 ms: a request waits in line for a free thread far longer than it spends doing work. That is a ~5x gap, and it widens with concurrency — the async numbers scale with the connection count while the sync numbers are pinned to the thread pool. No reasonable sync pool catches the event loop: async is simply the right tool for I/O-bound work, and that gap is the whole point of this test.
Falcon is among the fastest frameworks in this benchmark. Its async (ASGI) setup is right at the top on the ping test, and even its threaded (WSGI) setup reaches the top tier at ~120k req/s — once it is served by Granian and given one blocking thread per worker so its CPU-bound endpoint isn't slowed by intra-process GIL contention. Earlier rounds made Falcon look slow, but they were really measuring Gunicorn: its default sync worker closes the connection after every response, forcing a new TCP connection per request, which exhausts the local ephemeral port range under load and silently caps throughput. Granian served the exact same Falcon app roughly 12x faster.
Once given a fair number of processes (--processes=9 rather than --workers=9 — see the note in the General section), Robyn lives up to its Rust reputation. On the CPU-bound endpoint its threaded setup runs roughly 2x FastAPI's and Starlette's threaded setups (which route sync handlers through a thread pool), and it is right at the top on the ping test too. On the ping test the threaded (sync) setup beats async, because Robyn schedules each async handler as a coroutine on the event loop and that per-request overhead outweighs a direct sync call when the handler does almost nothing. The picture flips under real I/O (see the I/O load test): with short awaits per request, Robyn's async setup (~53k) scales with the load, while its sync setup (~12k, on a pool matched to the other servers) is pinned to its thread count — async is the right choice there. (Robyn now also installs cleanly under Python 3.13, which was not the case in earlier rounds.)
Both performed as expected.
Clone this repo locally. Install the dependencies with uv (uv sync) and install a benchmarking tool like Wrk or Oha.
The first command in every example will run the specified framework, and one of the other commands will do the measurements. Prefix the server commands with uv run to use the project environment.
To run the full suite at once: uv run python run_bench.py.
# trivial / CPU endpoints
granian --interface wsgi falcon_threaded:app --workers 9 --blocking-threads 1
# blocking I/O endpoint keeps the default auto-sized pool
granian --interface wsgi falcon_threaded:app --workers 9
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/io
oha -z 20s -c 500 http://127.0.0.1:8000/ping
oha -z 20s -c 100 http://127.0.0.1:8000/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/io
uvicorn falcon_async:app --log-level critical --workers=9
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/io
oha -z 20s -c 500 http://127.0.0.1:8000/ping
oha -z 20s -c 100 http://127.0.0.1:8000/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/io
# everything except the blocking sync endpoint (async/io is fastest here too)
python -m robyn robyn_threaded_async.py --processes=9 --workers=1
# only the blocking sync endpoint (threaded/io) needs a real thread pool
python -m robyn robyn_threaded_async.py --processes=9 --workers=40
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/threaded/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/threaded/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/threaded/io
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/async/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/async/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/async/io
oha -z 20s -c 500 http://127.0.0.1:8000/threaded/ping
oha -z 20s -c 100 http://127.0.0.1:8000/threaded/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/threaded/io
oha -z 20s -c 500 http://127.0.0.1:8000/async/ping
oha -z 20s -c 100 http://127.0.0.1:8000/async/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/async/io
uvicorn fastapi_threaded_async:app --log-level critical --workers=9
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/threaded/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/threaded/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/threaded/io
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/async/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/async/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/async/io
oha -z 20s -c 500 http://127.0.0.1:8000/threaded/ping
oha -z 20s -c 100 http://127.0.0.1:8000/threaded/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/threaded/io
oha -z 20s -c 500 http://127.0.0.1:8000/async/ping
oha -z 20s -c 100 http://127.0.0.1:8000/async/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/async/io
uvicorn starlette_threaded_async:app --log-level critical --workers=9
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/threaded/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/threaded/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/threaded/io
wrk --duration 20s --threads 1 --connections 500 http://127.0.0.1:8000/async/ping
wrk --duration 20s --threads 1 --connections 100 http://127.0.0.1:8000/async/uuid
wrk --duration 20s --threads 1 --connections 2000 http://127.0.0.1:8000/async/io
oha -z 20s -c 500 http://127.0.0.1:8000/threaded/ping
oha -z 20s -c 100 http://127.0.0.1:8000/threaded/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/threaded/io
oha -z 20s -c 500 http://127.0.0.1:8000/async/ping
oha -z 20s -c 100 http://127.0.0.1:8000/async/uuid
oha -z 20s -c 2000 http://127.0.0.1:8000/async/io