← Blog

Stario 5 — a compiled engine, HTTP/2, and all of your cores

Adam Bobowski · October 2026

Stario 5 keeps the shape of a Stario app and replaces the engine under it. The HTTP protocol is compiled now: llhttp parses HTTP/1, nghttp2 speaks HTTP/2, and the request, the writer, the router, and the HTML renderer are Cython. On one core that is about twice the throughput of 4.3 on the same machine. On free-threaded Python 3.14t, STARIO_THREADS=4 runs four event loops in one process and the in-process Relay keeps working across them, so a live page scales to four cores without a message broker.

The handler did not change: async def handler(c, w). What you write inside it got shorter.

import asyncio
 
from stario import App, Context, Route, Span, Writer, h, serve
 
HOME = Route("GET /")
 
 
async def home(c: Context, w: Writer) -> None:
    w.html(h.H1("Hello, Stario 5"))
 
 
async def bootstrap(app: App, span: Span):
    app.add(HOME, home)
    yield
 
 
if __name__ == "__main__":
    asyncio.run(serve(bootstrap))

python main.py runs it. There is no CLI any more; serve() is the whole entry point, and uvloop.run(serve(bootstrap)) is the uvloop version. The rest of this post is why, with numbers.

The core is compiled#

Stario 4 parsed HTTP with httptools and did everything after that in Python: the request object, header lookups, the query string, routing, response framing, compression. Each step cost a few hundred nanoseconds, and every request paid all of them. In 5 that path is one Cython protocol. Headers land in an arena and are indexed as they arrive. The router is a compiled trie walked from the raw path. The response is framed in C. Brotli, zstd, and gzip run through bundled native libraries, so the Python brotli and zstandard packages are no longer dependencies. The HTML renderer walks the tree in Cython too, about three times faster than the Python walk on a hundred-row page.

Nothing you call changed because of this. c.req.headers.get("host"), c.match.params["id"], w.respond(...) are the same names with the same results, backed by C.

Here is the released 4.3.0 wheel against 5.0.4, same app, same machine, back to back. Each cell is the median of five 20-second wrk runs.

EndpointStario 4.3.0Stario 5.0.45.0 / 4.3
GET /plaintext67,186132,4301.97×
GET /json64,707123,8081.91×
GET /user/4259,399115,3531.94×
POST /validate (JSON in, JSON out)49,370102,2102.07×

Requests per second. Both wheels returned the same bytes on the wire — a plaintext response was 130 bytes including headers. The POST gains most, because parsing a body and building a response is where the Python work used to be. This is the upgrade you get by changing one line in pyproject.toml.

Against the field#

The strongest server we could put on the other side of this table is Granian — a Rust host for Python apps. The 2.8.4 process ran a minimal ASGI app with the same four handlers and the same JSON bodies: one worker, runtime-mode mt, no access log. That is Granian itself, not a framework on top of it. Stario runs the same routes with its router, middleware, a request object, and a span on every request.

EndpointGranian 2.8.4Stario 5.0.45.0 / Granian
Plaintext88,701132,4301.49×
JSON85,928123,8081.44×
Params87,316115,3531.32×
Validate47,196102,2102.17×

Latency moves with it. Each cell is the median of the five runs' wrk percentiles, in milliseconds:

EndpointServerp50p99
PlaintextGranian1.511.91
PlaintextStario 4.3.01.882.57
PlaintextStario 5.0.40.961.17
JSONGranian1.551.92
JSONStario 4.3.01.953.03
JSONStario 5.0.41.021.46
ParamsGranian1.522.03
ParamsStario 4.3.02.132.99
ParamsStario 5.0.41.101.41
ValidateGranian2.773.42
ValidateStario 4.3.02.517.48
ValidateStario 5.0.41.241.64

And the fair-share note that makes the table honest: Stario used 1.00 core. Granian's Rust I/O thread plus its Python loop used about 1.4 cores — 1.57 on validate. The requests-per-second table already gives Granian that extra core.

The rig#

A 4-vCPU KVM guest on an Intel Xeon (Emerald Rapids, fixed at 2400 MHz), 15.6 GiB of memory, Ubuntu 24.04.5, kernel 6.12.94. wrk 4.1 ran pinned to CPUs 0–1 and each server to CPUs 2–3: two threads, 128 connections, HTTP/1.1 keep-alive, a five-second warmup thrown away, then twenty seconds measured, five runs per target with targets rotated. Python 3.14.8, uvloop 0.23.0, ujson 6.0.0; both Stario versions are the published wheels with tracing off and compression disabled. All sixty runs finished with no socket errors and no non-2xx responses, and bodies, status codes, and content types matched on all four routes.

The five runs of each cell sat in a tight band everywhere but Granian's validate route, which ranged 36,685–49,553 around its median of 47,196. Stario's validate stayed inside 101,319–103,046.

One thing not to take from the tables: these are hello-world shapes, and a real request spends its time in your code and your database. What the numbers say is that the framework will not be the slow part — even when the other side is Rust.

HTTP/2 and TLS#

The same connection class speaks HTTP/2. A client that negotiates h2 over TLS, or sends the cleartext preface, gets frames instead of HTTP/1.1 text: multiplexed streams, per-stream flow control, and a two-phase GOAWAY on shutdown so requests in flight finish. Nothing in a handler knows which protocol it is on.

Stario can also terminate TLS itself: STARIO_SSL_CERTFILE and STARIO_SSL_KEYFILE, or ServerConfig(ssl=...), with ALPN offering h2 then http/1.1. A proxy in front is still the normal deployment, and Yard still puts Caddy there, but one container can now be a complete HTTPS server.

h2load against one worker, 300,000 requests over 100 connections:

ProtocolRequests per second
HTTP/1.1163,961
HTTP/2, 10 streams per connection148,755
HTTP/2, 100 streams per connection187,111

HTTP/2 costs a little at low concurrency per connection and wins once a connection carries many streams, which is what browsers do.

One process, every core#

This is the part I am happiest about. Python 3.14t runs without the GIL, and Stario 5 uses that the only way asyncio can be used safely: one event loop per thread. STARIO_THREADS=4 starts four threads, each with its own loop and its own listening socket on the same port through SO_REUSEPORT, and the kernel spreads connections across them. Bootstrap runs once. Signals and shutdown belong to thread 0.

The reason to want this, rather than four processes, is Relay. A live page in Stario is one open response fed by an in-process publish-subscribe bus. Fork into processes and that bus needs Redis or NATS the moment it exists. Threads keep it in the process: a publish on one loop reaches a subscriber on another with one call_soon_threadsafe per burst, and the chat room keeps working on four cores with no broker.

The measurement needs a Linux kernel, because macOS does not balance SO_REUSEPORT across sockets (more on that below), so these ran on the same 4-vCPU VM, with wrk sharing those four cores. First a handler that does about 1.7 ms of pure Python per request, which is the case the GIL made hopeless:

ThreadsPythonRequests per secondAverage latency
13.14 with the GIL1,42862 ms
13.14t1,25176 ms
23.14t2,78725 ms
43.14t3,88419 ms
43.14 with the GIL, forced1,322136 ms

Four free-threaded loops do 3.1 times the work of one, on four shared cores. Four threads with the GIL do what one does, with worse latency, which is why Stario refuses to start them unless you set STARIO_THREADS_ALLOW_GIL=1.

Then the thin handler from the framework suite, wrk -t1 -c128:

ThreadsPlaintextJSON
1, with the GIL114,95790,186
1, 3.14t90,59099,924
2, 3.14t234,870197,024
4, 3.14t263,772245,247

The honest caveats. Free-threaded Python is slower than the GIL build on one thread: 12 percent on the CPU-bound handler here, about 20 percent on plaintext. The win is cores, not single-core speed, so measure before you turn it on. Threads are opt-in, default 1, and need 3.14t. Your shared state has to be thread-safe, which for most apps means a threading.Lock around a dict, or a single-writer SQLite. And on macOS, SO_REUSEPORT puts every new connection on one socket, so STARIO_THREADS=4 on a Mac starts four loops and uses one; develop with threads on a Mac if you like, but benchmark on Linux. Free-threaded Stario is the design note.

The handler got shorter#

A few changes to the surface, each removing something you had to know:

  • Responses are methods on the writer: w.text, w.json, w.html, w.redirect, w.empty. stario.responses is gone.

  • from stario import h gives you the HTML tags, and classes is exported from the root too. The documented import is from stario import App, Context, Route, Span, Writer, h.

  • serve(bootstrap) reads STARIO_* from the environment when you pass no config. python main.py is the server; uvx watchfiles 'python main.py' . is hot reload.

  • TestClient runs the production protocol. Each request becomes HTTP/1.1 bytes fed to the real parser on an in-memory connection, so routing, 308 and 400 answers, framing, compression, and the request span are the server's own. A test sees exactly what a browser would get.

  • Middleware composes at compile time. app.use and app.add work in any order, and scope middleware wraps the 404 and 405 under its prefix, including your own not_found handler.

  • await w.drain() is write-side backpressure for streaming handlers. Files and Assets use it, so a slow client no longer makes the server read a whole file into memory.

  • Paths follow RFC 3986. Segments are decoded one at a time, so /files/a%2Fb is one parameter containing a slash, and req.raw_path is the path as sent.

And the deprecations from 4.2 are gone for good: UrlPath, app.get, Route.get, at.fetch, StaticAssets.

HTML, since we are measuring#

The renderer is on the same benchmark page as the server. A 50-row product page, autoescaping on everywhere, best of seven runs:

RendererPer page
Stario, @baked layout41 µs
Jinja2, compiled template111 µs
Stario, full tree per call257 µs
tdom (3.14 t-strings)695 µs
htpy782 µs
dominate1,060 µs

A @baked layout is a Python function whose static parts are rendered once at import; only the slots are filled per call. That is how HTML in Python ends up faster than a compiled template.

Upgrading from 4.x#

  • pyproject.toml: stario>=5,<6. Stario is a compiled package now. PyPI has wheels for Linux x86_64 and aarch64 and macOS arm64, on CPython 3.12 to 3.14 and 3.14t. Other platforms build from source and need a C compiler plus nghttp2, Brotli, and zstd headers. Windows is not supported.

  • responses.text(w, ...) → w.text(...), and the same for json, html, redirect, empty.

  • from stario.markup import html as h → from stario import h.

  • stario serve app.main:bootstrap → python -m app.main with asyncio.run(serve(bootstrap)) under if __name__ == "__main__":. stario watch → uvx watchfiles 'python -m app.main' app/.

  • STARIO_LOOP=uvloop → uvloop.run(serve(bootstrap)) in your entry point.

  • HttpException and app.on_error are gone. Write the response you mean, w.text("Not found", status=404), or map your own exception types with stario.http.middleware.catch_errors. Redirects and body-size errors are answered by the server itself.

  • c.req.headers is read-only. Middleware that used to set a request header should put the value in c.state.

  • If you relied on %2F or dot segments in paths being passed through undecoded, check your parameter handling.

The changelog has every line. The docs are rewritten for 5 from the first page to the reference.

What did not change#

A handler is async def handler(c, w). A route is a value. HTML is Python. A live page is one open response. Every request is a span. Those five were the point of Stario, and the compiled core exists so that none of them has to be traded for speed.

By the way#

  • Every number above came from benchmarks/server and benchmarks/html in the repository. Run them on your own machine; the numbers that matter are yours.

  • 5.0 was built on a branch called cython-core over seven weeks, from the first experimental protocol on 24 August to the merge on 12 October, which was one commit.

  • Yard runs Stario 5 as its own host process: uvloop.run(serve(bootstrap)) and nothing else.