← Blog

How fast is Stario? Benchmarks against FastAPI, BlackSheep, Sanic, Falcon, and Robyn

Adam Bobowski · November 2026

We rebuilt Stario’s benchmark suite so that it compares like with like: the same endpoints in every framework, the same CPU budget, and the same output, checked before anything is timed. This post covers how it works and what it shows: Stario 5.7.0 on one core, and Stario 5.8.0 on two. The code, the raw results, and a longer write-up are in the Stario repository: benchmarks/.

Method#

The server suite is benchmarks/server/bench.py. It sets up a virtual environment per framework and runs one server at a time. Each server is pinned to a single CPU with taskset, including any Rust helper threads. The load generator, wrk, runs on two other CPUs. Every server sat at 99–100% of its core in every wrk run, so the server, not the client, was the limit.

Before measuring, the runner fetches every endpoint of every framework and checks the answers. The HTML page has to be the same document everywhere. Socket errors and non-2xx responses are reported next to each number. Each number is the median of three 10-second runs, after a warm-up.

The endpoints are the usual plaintext, JSON, a path parameter, and a small JSON POST, plus two that look more like a real app:

  • /page, a 16 KB admin page with a 50-row table, where one row in seven needs escaping. Stario builds it with h.*; the other frameworks render it with a compiled Jinja2 template, which is what their docs reach for.

  • A browser-shaped mix: 40% page, 30% JSON with a path parameter, 20% JSON, 10% POST. Every request carries a real Chrome header set with cookies.

The HTML suite, benchmarks/html/compare.py, renders the same product list with Stario, Jinja2, Mako, htpy, dominate, FastHTML’s fastcore.xml, and tdom. Hand-written f-strings serve as baselines. Every output is checked against Stario’s.

Some choices favor one side, and you should know them:

  • Pinning everything to one core favors single-threaded event loops. Granian and Robyn run Rust I/O threads, which here share the core with Python.

  • FastAPI validates the POST with Pydantic, as its users would; the others check fields by hand.

  • Stario runs with its tracer off, the counterpart of the access logs turned off elsewhere.

  • Robyn’s const=True response cache is off, so every framework runs its handler per request.

  • The listen backlog is 2048 everywhere it can be set. Robyn has no setting for it.

An independent review of the first draft caught a bug that doubled Jinja2’s render time: the template used r.id on dicts, and Jinja2 tries attribute access before indexing. The numbers below are from the rerun with that fixed.

The machine#

The VM had 4 vCPUs (Intel Xeon at 2.10 GHz), 15 GB of RAM, and ran Linux 6.18. Everything ran on CPython 3.14.6 with uvloop 0.23 (3.14.6t for free-threaded Stario). Versions: Stario 5.7.0 for one core and 5.8.0 for two, FastAPI 0.162 on Uvicorn 0.54, BlackSheep 2.6.4 on Uvicorn and on Granian 2.8.7, Sanic 25.12, Falcon 4.4, and Robyn 0.88. A cloud VM drifts during the day, so all the server numbers below come from one contiguous run. The exceptions are the SSE test, which ran earlier the same day and doesn’t involve the template, and the two-core section, described there.

One core#

Requests per second at 128 connections:

FrameworkPlaintextJSONHTML pageBrowser mix
Stario82,50666,8957,78914,526
BlackSheep + Granian52,57152,7934,6599,010
BlackSheep + Uvicorn24,92425,0194,8628,929
Falcon + Uvicorn24,19623,2354,9658,507
Sanic23,42522,3054,9208,333
Robyn31,89330,6164,9528,315
FastAPI + Uvicorn13,06712,8334,0175,947

On small responses, Stario ranges from level with the fastest other setup, BlackSheep on Granian, to 1.9x it, and is 5–6x FastAPI. Most of that comes from the compiled HTTP core. On the HTML page it is 1.6x the next framework. The same page through @baked, which compiles the static parts once, serves 12,329 pages/s, 2.5x the next framework.

Latency follows throughput. In the browser mix, Stario’s p99 is 15 ms. The other frameworks range from 17 ms (BlackSheep on Uvicorn) to 309 ms (FastAPI).

With compression#

With a browser’s Accept-Encoding, Stario picks zstd and serves the mix at 13,240 req/s, at 792 bytes per response. FastAPI and BlackSheep compress with gzip and manage 3,800–6,000 req/s. Sanic, Falcon, and Robyn have no built-in compression and usually sit behind a proxy that does it.

With gzip forced to level 1 everywhere, Stario serves the HTML page at 1.75x the best of the others, against 1.6x uncompressed. Compression costs Stario a little less, about 14% of its page throughput against 18–25% for the others, and the rest of the gap comes from the request path.

A heavier load#

10,000 keep-alive connections each fetch the HTML page every 2 seconds. That’s 5,000 page requests per second, close to what the other frameworks can serve on one core. Stario serves it with p50 1.1 ms, p99 26 ms, and no errors. Most of the others serve 4,100–4,500 req/s but queue, with median latency from 240 ms to 1.7 s; FastAPI falls to 2,850 req/s. Four of the six report errors. This is the throughput result again, seen as latency under a fixed load.

For live updates, 5,000 clients hold a Datastar SSE stream while the server pushes an HTML fragment to all of them twice a second. Stario delivers at a median of 54 ms using 0.19 of a core. Sanic is level with it, and the others are close. Fan-out of small events is cheap for any async server, so this is not where Stario stands out.

HTML rendering on its own#

Microseconds per 50-row page, with no server involved:

LibraryClean textEscape-heavy
Stario @baked17.720.0
f-strings + html.escape20.629.4
f-strings + markupsafe35.638.7
Stario plain h.* tree40.739.5
Jinja248.954.6
Mako59.162.1
tdom, FastHTML FT, htpy, dominate919–1,045908–1,067

@baked is slightly faster than hand-written f-strings. The plain tree, where every element is an object you can inspect, comes in 1.2–1.4x faster than Jinja2. The pure-Python element libraries are about 20x slower than either.

Two cores with Stario 5.8#

Stario’s multi-core mode runs on free-threaded Python 3.14t: STARIO_THREADS=2 starts two event loops in one process, so in-process state such as Relay is shared across cores. The other frameworks start two processes. For a like-for-like baseline we also ran two regular Stario processes sharing the port. Stario doesn’t ship that mode; the benchmark has a small launcher for it, and each process would have its own Relay.

The setup: two server CPUs, three rounds with the frameworks in a different order each time, and the median of the three. Stario 5.8.0 was built from its release commit, 03d54a9, because the PyPI wheels were not out yet; a later run with the published wheels came out within 4% on the page and mix.

Setup on 2 coresPlaintextHTML pageBrowser mix
Stario, 2 processes228,89515,55931,466
Stario, 2 threads (free-threaded)140,29810,53422,496
Robyn, 2 processes65,93310,03917,484
Sanic, 2 workers50,54010,04016,529
BlackSheep + Granian, 2 workers75,3948,75318,042
FastAPI, 2 workers26,7398,24311,903

Read the page and mix columns: plaintext is near the load generator’s limit at these rates and shifts with the tool used to measure it, so its ratios are rough. Two Stario processes (the benchmark launcher, not a shipped mode) serve 1.55x the pages and 1.74x the mix of the best two-process setup. Two free-threaded threads match Robyn and Sanic on the page and lead the mix by 1.25x, from a single process.

5.8.0 is what made the threaded row possible. In 5.7.0, threads contended on the reference counts of shared markup objects, and a second thread made the page slower (0.94x). In 5.8.0, two threads serve 1.65x the pages of one, while two processes still serve 2.05x the pages of one. Part of that gap is 3.14t’s per-thread cost; part is this benchmark’s page reading the same module-level rows from both threads, which plain Python on 3.14t doesn’t scale well either.

Where Stario is still weak#

At 1,024 concurrent connections, a minority of requests on uvloop servers, Stario and Uvicorn alike, wait several seconds while the median stays low. Granian and Robyn keep a fair tail at that load. The same Stario on stdlib asyncio is fair too, at half the throughput, so we are looking at how libuv schedules reads.

On free-threaded Python, a thread costs Stario about 16% against the regular build, and HTML work still scales less well across threads than across processes (see “Two cores with Stario 5.8”).

Run it yourself#

Numbers from one machine are a starting point. The suite runs on any Linux box with four or more CPUs, uv, and wrk:

python3 benchmarks/server/bench.py   # every framework, every phase: about an hour
benchmarks/server/run.sh             # throughput only: about 15 minutes

The results write-up has every table, the concurrency sweep, memory, and the list of what we plan to improve.