Performance / Benchmarks¶
Four workloads, ten frameworks, one worker on one dedicated core each. Every app, the harness, and the full methodology are public at api-benchmarks.
TL;DR: jero is the fastest Python framework on all four workloads, by 1.03× to 1.27× over Django Bolt and 1.35× to 4.8× over Blacksheep, Litestar, and FastAPI. Per core, it beats Go's Gin on all four and is the fastest of all ten on three of them; only Bun's Elysia is faster, on the pure framework path.
Each panel is scaled to its own fastest framework; the labels show absolute throughput.
Setup¶
| Setting | Value |
|---|---|
| Machine | Amazon EC2 c9g.2xlarge spot, eu-central-1, one framework at a time |
| Machine specs | AWS Graviton5 (arm64), 8 vCPUs (physical cores, no SMT), 16 GiB |
| CPU | 1 dedicated core per framework (cpuset affinity) |
| Workers | 1 (Gin at GOMAXPROCS=1, Go's own default on one core; Django Bolt at --processes 1) |
| Spring Boot | Tomcat with virtual threads, JVM defaults (no heap flags) |
| Load generator | wrk 4.2.0, 2 threads on 2 dedicated cores |
| Concurrency | 128 connections, closed loop |
| Duration | 60s per attempt, after an untimed 3s warm-up |
| Best-of-N | 3 attempts, highest throughput kept |
| Python | 3.14.9 (uv's standalone CPython, every Python framework) |
| Python server | peregrine 1.2.0 + uvloop (Flask via peregrine WSGI; Robyn and Django Bolt ship their own Rust servers) |
| Outbound client | lothc 0.3.0 (native transport) for every Python framework |
| Database driver | fastpg for the async Python fleet; psycopg for sync Flask |
| Pools | 64 connections per framework, database and outbound HTTP alike |
| Versions | jero 0.4.0, Django Bolt 0.13.0, FastAPI 0.162.5 |
| Run | 20261113T094529, all ten frameworks, api-benchmarks commit da79feb |
- Correctness first. Every framework passed a 100-request body check before load and returned 100% expected statuses under it.
- Shared libraries. Every Python framework uses the same HTTP client (lothc), JWT decoder (ryjwt), and Postgres driver (fastpg; Django Bolt included, bypassing Django's ORM). jero's author wrote all three, and this benchmark.
- Native code. The Python stacks do their JSON, JWT, database, and HTTP-client work
in C and Rust extensions; Gin uses
encoding/json, golang-jwt, and pgx. - Otherwise idiomatic. The differences between apps are listed under "Known, intended differences" in the api-benchmarks README.
- Latency is under full load (128 connections, closed loop), where tails run optimistic.
Results¶
req/s higher is better; mean and p99 lower is better.
1. GET /info: the pure framework path¶
Route, build a typed JSON response with a custom header, encode. No I/O.
| Framework | req/s | mean | p99 | vs jero |
|---|---|---|---|---|
| elysia (Bun) | 144.2k | 0.89ms | 1.86ms | 1.44× |
| jero | 99.9k | 1.27ms | 1.32ms | 1.00× |
| django-bolt | 91.2k | 1.40ms | 1.43ms | 0.91× |
| gin (Go) | 88.1k | 1.46ms | 3.28ms | 0.88× |
| blacksheep | 74.2k | 1.72ms | 1.77ms | 0.74× |
| litestar | 49.2k | 2.59ms | 2.64ms | 0.49× |
| robyn | 43.6k | 2.93ms | 3.04ms | 0.44× |
| spring-boot (Java) | 41.7k | 3.06ms | 4.86ms | 0.42× |
| fastapi | 29.5k | 4.32ms | 4.45ms | 0.30× |
| flask | 26.9k | 4.74ms | 4.86ms | 0.27× |
2. POST /movies: JWT auth and body validation¶
JWT auth, decode and validate a five-field body, return it with a new id, 201. No I/O.
| Framework | req/s | mean | p99 | vs jero |
|---|---|---|---|---|
| jero | 84.0k | 1.51ms | 1.57ms | 1.00× |
| django-bolt | 81.8k | 1.56ms | 1.59ms | 0.97× |
| gin (Go) | 53.7k | 2.39ms | 5.07ms | 0.64× |
| elysia (Bun) | 40.7k | 3.14ms | 6.06ms | 0.48× |
| blacksheep | 33.6k | 3.78ms | 3.84ms | 0.40× |
| litestar | 30.3k | 4.20ms | 4.27ms | 0.36× |
| spring-boot (Java) | 26.8k | 4.77ms | 7.31ms | 0.32× |
| robyn | 26.7k | 4.78ms | 4.86ms | 0.32× |
| flask | 20.1k | 6.35ms | 6.49ms | 0.24× |
| fastapi | 17.6k | 7.26ms | 7.44ms | 0.21× |
3. GET /catalog: the outbound hop¶
An authenticated call to a Rust upstream, decoded into a typed model and returned.
| Framework | req/s | mean | p99 | vs jero |
|---|---|---|---|---|
| jero | 51.8k | 2.46ms | 2.65ms | 1.00× |
| elysia (Bun) | 44.8k | 2.85ms | 4.81ms | 0.87× |
| django-bolt | 41.9k | 3.05ms | 4.03ms | 0.81× |
| gin (Go) | 32.7k | 3.91ms | 7.14ms | 0.63× |
| blacksheep | 31.8k | 4.01ms | 4.29ms | 0.62× |
| litestar | 26.6k | 4.82ms | 7.65ms | 0.51× |
| robyn | 23.1k | 5.54ms | 8.41ms | 0.45× |
| spring-boot (Java) | 20.0k | 6.40ms | 9.80ms | 0.39× |
| flask | 16.7k | 7.64ms | 7.96ms | 0.32× |
| fastapi | 11.7k | 11.07ms | 28.19ms | 0.23× |
Elysia relays the upstream JSON without decoding it into a typed model.
4. GET /users/me: the database read¶
JWT auth, then one Postgres row by the token's subject. Gin, Elysia, and Spring Boot use their native drivers.
| Framework | req/s | mean | p99 | vs jero |
|---|---|---|---|---|
| jero | 46.2k | 2.76ms | 3.33ms | 1.00× |
| gin (Go) | 37.8k | 3.39ms | 7.14ms | 0.82× |
| django-bolt | 36.3k | 3.52ms | 4.35ms | 0.79× |
| blacksheep | 29.7k | 4.31ms | 5.39ms | 0.64× |
| elysia (Bun) | 26.5k | 4.82ms | 9.34ms | 0.57× |
| robyn | 22.1k | 5.79ms | 8.62ms | 0.48× |
| litestar | 22.0k | 5.83ms | 8.44ms | 0.48× |
| spring-boot (Java) | 20.2k | 6.33ms | 10.29ms | 0.44× |
| fastapi | 10.0k | 12.93ms | 30.44ms | 0.22× |
| flask | 9.0k | 14.26ms | 15.79ms | 0.19× |
Caveats¶
- Only one core was measured. Go and the JVM scale across cores inside one process; Python and Bun add processes, each with its own memory (~40–83M per Python worker here, against Gin's ~10–28M).
- Spring Boot ran on JVM defaults at ~615–673M resident; a tuned footprint was not measured.
- A benchmark is not your app. Fixed payloads on an idle host. Measure the workload you care about.
What middleware and CORS cost¶
Middleware and CORS compile at wiring, so routes
they don't cover pay nothing. Relative cost on the in-process hot-path harness
(bench.py's POST-echo shape, ~1.3µs/request baseline; in-process numbers exaggerate
deltas compared with a real server):
| configuration | relative cost |
|---|---|
| no middleware, no CORS | 1.00× |
wildcard CORS / a constant response_headers block |
~1.02× |
| origin allow-list CORS | ~1.15× |
on-scope intercept (falling through) |
~1.4× |
observe |
~1.6× |
response_headers method |
~1.9× |
For contrast, one onion-style ASGI wrapper appending a single constant CORS pair costs ~1.3× on every request.
Measure it yourself¶
One script runs jero, Litestar, BlackSheep, and FastAPI in-process as bare ASGI
callables (no server, no sockets) against a hand-rolled raw ASGI app doing the same
work, the ceiling for that work. With no server in the loop the gaps run far wider than in
the tables above, so read each framework's distance from raw, not its throughput.
curl -LsSf https://raw.githubusercontent.com/RogerThomas/jero/main/benchmarks/micro_bench.py | uv run -
The source is below; save it as micro_bench.py and uv run micro_bench.py to run it
locally.
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "blacksheep",
# "fastapi",
# "jero",
# "litestar",
# "msgspec",
# "rich",
# ]
# ///
"""In-process ASGI micro-benchmark: jero vs Litestar vs BlackSheep vs FastAPI vs a
hand-rolled raw ASGI app, all serving the same three-endpoint API.
Every app is driven directly as an ASGI callable — no server, no sockets — so what you
measure is each framework's own per-request overhead: routing, binding/validation, and
serialization. That makes the numbers a per-core ceiling, not a production promise.
Scenarios (identical raw ASGI scopes for every app):
plain GET /ping -> tiny JSON body
bind GET /items/{item_id}?limit=10 -> path + query binding, JSON body
body POST /items (JSON, 3 fields) -> body decode/validation, JSON echo
Fairness notes:
- The "raw ASGI" app uses no framework but still does honest work with msgspec:
hand-rolled routing, path/query extraction, a typed validating decode of the POST
body, and a typed encode of every response. It skips everything a framework gives
you (404/405 semantics, HEAD/OPTIONS, content-type checks, error envelopes).
- Each framework app is written the way its own documentation recommends.
- BlackSheep uses its documented msgspec JSON hook (its fastest configuration).
- Tweak freely: pin versions in the dependencies block above, change N_MEASURE,
add a framework, add a scenario.
"""
import asyncio
import time
N_WARMUP = 2_000
N_MEASURE = 20_000
BEST_OF = 3
POST_BODY_CAMEL = b'{"name":"gizmo","priceCents":499,"tags":["a","b"]}'
POST_BODY_SNAKE = b'{"name":"gizmo","price_cents":499,"tags":["a","b"]}'
# --------------------------------------------------------------------- harness
def make_scope(method: str, path: str, query: bytes = b"", body: bool = False) -> dict:
headers = [(b"host", b"bench")]
if body:
headers.append((b"content-type", b"application/json"))
return {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": method,
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": query,
"root_path": "",
"headers": headers,
"client": ("127.0.0.1", 1234),
"server": ("127.0.0.1", 80),
}
SCENARIOS = [
("plain GET", make_scope("GET", "/ping"), False),
("path+query GET", make_scope("GET", "/items/abc123", query=b"limit=10"), False),
("POST w/ body", make_scope("POST", "/items", body=True), True),
]
async def run_lifespan_startup(app) -> None:
"""Send lifespan.startup and wait for completion (frameworks wire routes here)."""
started = asyncio.Event()
async def receive():
return {"type": "lifespan.startup"}
async def send(message):
if message["type"] == "lifespan.startup.failed":
raise RuntimeError(message.get("message", "lifespan startup failed"))
if message["type"] == "lifespan.startup.complete":
started.set()
task = asyncio.ensure_future(app({"type": "lifespan"}, receive, send))
await started.wait()
task.cancel()
async def bench_one(app, scope_template: dict, body: bytes | None) -> float:
"""Seconds for N_MEASURE requests (asserts 2xx on every response)."""
def make_receive():
sent = False
async def receive():
nonlocal sent
if body is not None and not sent:
sent = True
return {"type": "http.request", "body": body, "more_body": False}
return {"type": "http.disconnect"}
return receive
async def send(message):
if message["type"] == "http.response.start":
assert 200 <= message["status"] < 300, f"unexpected status {message['status']}"
async def one():
await app(dict(scope_template), make_receive(), send)
for _ in range(N_WARMUP):
await one()
start = time.perf_counter()
for _ in range(N_MEASURE):
await one()
return time.perf_counter() - start
# ------------------------------------------------------- raw ASGI (honest work)
def build_raw():
import msgspec
class Ok(msgspec.Struct):
ok: bool
class ItemIn(msgspec.Struct, rename="camel"):
name: str
price_cents: int
tags: list[str]
class Item(msgspec.Struct, rename="camel"):
id: str
name: str
price_cents: int
tags: list[str]
encoder = msgspec.json.Encoder()
item_in_decoder = msgspec.json.Decoder(ItemIn)
async def send_json(send, payload: bytes) -> None:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", b"%d" % len(payload)),
],
}
)
await send({"type": "http.response.body", "body": payload})
async def app(scope, receive, send):
method, path = scope["method"], scope["path"]
if method == "GET" and path == "/ping":
await send_json(send, encoder.encode(Ok(ok=True)))
elif method == "GET" and path.startswith("/items/"):
item_id = path[len("/items/") :]
limit = 20
for pair in scope["query_string"].decode("latin-1").split("&"):
key, _, value = pair.partition("=")
if key == "limit" and value:
limit = int(value)
item = Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])
await send_json(send, encoder.encode(item))
elif method == "POST" and path == "/items":
chunks = []
while True:
message = await receive()
chunks.append(message.get("body", b""))
if not message.get("more_body"):
break
data = item_in_decoder.decode(b"".join(chunks))
item = Item(id="new", name=data.name, price_cents=data.price_cents, tags=data.tags)
await send_json(send, encoder.encode(item))
else:
raise AssertionError(f"unrouted request {method} {path}")
return app
# ------------------------------------------------------------------------ jero
def build_jero():
from jero import BaseApp, Endpoint, Struct
class Ok(Struct):
ok: bool
class ItemPath(Struct):
item_id: str
class PageParams(Struct):
limit: int = 20
class ItemIn(Struct, rename="camel"):
name: str
price_cents: int
tags: list[str]
class Item(Struct, rename="camel"):
id: str
name: str
price_cents: int
tags: list[str]
class PingEndpoint(Endpoint, path="/ping"):
async def get(self) -> Ok:
return Ok(ok=True)
class ItemEndpoint(Endpoint, path="/items/{item_id}"):
async def get(self, path: ItemPath, params: PageParams) -> Item:
return Item(id=path.item_id, name="gizmo", price_cents=params.limit, tags=["a"])
class ItemsEndpoint(Endpoint, path="/items"):
async def post(self, json: ItemIn) -> Item:
return Item(id="new", name=json.name, price_cents=json.price_cents, tags=json.tags)
class App(BaseApp):
async def wire(self) -> None:
self._include_endpoint(PingEndpoint())
self._include_endpoint(ItemEndpoint())
self._include_endpoint(ItemsEndpoint())
return App()
# -------------------------------------------------------------------- litestar
def build_litestar():
from dataclasses import dataclass
from litestar import Litestar, get, post
@dataclass
class ItemIn:
name: str
price_cents: int
tags: list[str]
@dataclass
class Item:
id: str
name: str
price_cents: int
tags: list[str]
@get("/ping")
async def ping() -> dict:
return {"ok": True}
@get("/items/{item_id:str}")
async def get_item(item_id: str, limit: int = 20) -> Item:
return Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])
@post("/items")
async def create_item(data: ItemIn) -> Item:
return Item(id="new", name=data.name, price_cents=data.price_cents, tags=data.tags)
return Litestar(route_handlers=[ping, get_item, create_item])
# ------------------------------------------------------------------ blacksheep
def build_blacksheep():
from dataclasses import dataclass
import msgspec
from blacksheep import Application, FromJSON, get, post
from blacksheep.settings.json import json_settings
# BlackSheep's documented hook for a faster JSON codec.
def msgspec_dumps(obj) -> str:
return msgspec.json.encode(obj).decode("utf-8")
json_settings.use(loads=msgspec.json.decode, dumps=msgspec_dumps)
app = Application()
@dataclass
class ItemIn:
name: str
price_cents: int
tags: list[str]
@dataclass
class Item:
id: str
name: str
price_cents: int
tags: list[str]
@get("/ping")
async def ping():
return {"ok": True}
@get("/items/{item_id}")
async def get_item(item_id: str, limit: int = 20):
return Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])
@post("/items")
async def create_item(item: FromJSON[ItemIn]):
data = item.value
return Item(id="new", name=data.name, price_cents=data.price_cents, tags=data.tags)
return app
# --------------------------------------------------------------------- fastapi
def build_fastapi():
from fastapi import FastAPI
from pydantic import BaseModel
class ItemIn(BaseModel):
name: str
price_cents: int
tags: list[str]
class Item(BaseModel):
id: str
name: str
price_cents: int
tags: list[str]
app = FastAPI()
@app.get("/ping")
async def ping() -> dict:
return {"ok": True}
@app.get("/items/{item_id}")
async def get_item(item_id: str, limit: int = 20) -> Item:
return Item(id=item_id, name="gizmo", price_cents=limit, tags=["a"])
@app.post("/items")
async def create_item(item: ItemIn) -> Item:
return Item(id="new", name=item.name, price_cents=item.price_cents, tags=item.tags)
return app
# ------------------------------------------------------------------------ main
async def main() -> None:
# (label, builder, needs_lifespan, post_body)
candidates = [
("raw (msgspec)", build_raw, False, POST_BODY_CAMEL),
("jero", build_jero, True, POST_BODY_CAMEL),
("Litestar", build_litestar, True, POST_BODY_SNAKE),
("BlackSheep", build_blacksheep, True, POST_BODY_SNAKE),
("FastAPI", build_fastapi, True, POST_BODY_SNAKE),
]
results: dict[str, dict[str, float]] = {}
for name, builder, needs_lifespan, post_body in candidates:
try:
app = builder()
except ImportError:
print(f"{name}: skipped (not installed)")
continue
if needs_lifespan:
await run_lifespan_startup(app)
results[name] = {}
for scenario, scope, has_body in SCENARIOS:
body = post_body if has_body else None
results[name][scenario] = min(
[await bench_one(app, scope, body) for _ in range(BEST_OF)]
)
from rich import box
from rich.console import Console
from rich.table import Table
def compact(rps: float) -> str:
return f"{rps / 1e6:.2f}M" if rps >= 1e6 else f"{rps / 1e3:.0f}k"
# Multipliers are throughput relative to jero (or, if jero was removed from
# the run, relative to the first app benchmarked) — jero = 1.00×, like the
# `vs jero` column in the tables above.
base = results.get("jero") or next(iter(results.values()))
table = Table(
title="Framework overhead per request",
caption=(
f"req/s · µs per request · throughput vs jero (higher is faster)\n"
f"{N_MEASURE:,} requests per cell, best of {BEST_OF}, "
f"in-process — no server, no sockets"
),
box=box.ROUNDED,
title_style="bold",
caption_style="dim",
header_style="bold",
padding=(0, 1),
)
table.add_column("framework", style="cyan", no_wrap=True)
for scenario, _, _ in SCENARIOS:
table.add_column(scenario, justify="right", no_wrap=True)
# Rows ordered by req/s, fastest → slowest, like the tables above.
for name, cells in sorted(
results.items(),
key=lambda item: sum(N_MEASURE / elapsed for elapsed in item[1].values()),
reverse=True,
):
row = ["[bold white]jero[/]" if name == "jero" else name]
for scenario, _, _ in SCENARIOS:
elapsed = cells[scenario]
us = elapsed / N_MEASURE * 1e6
mult = base[scenario] / elapsed
figures = f"{compact(N_MEASURE / elapsed):>5} {us:>5.1f}µs {mult:>5.2f}×"
row.append(f"[bold]{figures}[/]" if name == "jero" else figures)
table.add_row(*row)
# A fixed width keeps the table identical in any terminal (or piped to a file).
Console(width=96).print(table)
asyncio.run(main())