Lauren logoLauren
← Home
Export this page

Cross-Framework Performance Benchmarks

TL;DR. One harness drives nine participants — Lauren, FastAPI, Starlette, Litestar, BlackSheep, Sanic, Falcon, Quart and a hand-written ASGI floor — through five workloads, five repeats each, in-process through httpx.ASGITransport. Lauren lands mid-pack and stays there: 3rd to 6th of the eight frameworks measured on every workload, with a five-repeat range that overlaps Litestar's and Sanic's on all five workloads, FastAPI's on four and BlackSheep's and Falcon's on four, and is cleanly separated only from Quart. Starlette, Falcon and BlackSheep take the podium (in the top three on four or five workloads each); Quart is last on four of five. The hand-written floor — no framework in the request path at all — runs only 1.13x to 1.24x faster than the best measured framework, a margin narrower than the spread of the cells it compares: read that as "the whole framework layer costs a few tens of percent on this machine", not as a fixed factor the floor wins by. Cold start is where Lauren pays for its startup validation: 707 ms to a served request (median of five, spread 28%), against 552 ms for the floor, with 318 ms of that being imports and 98 ms constructing and validating the application. Two frameworks — aiohttp and robyn — could not be driven in-process at all and have no number here.


What this page reports

SectionWhat is in it
The machine and the dependency setCPU, interpreter, OS and the exact framework versions behind every number
The five workloadsThe request each participant had to answer
ThroughputMedian requests/second per workload, with the spread across repeats
Latencyp50 / p95 / p99 of the in-process round trip
StartupTime to serve for a cold process, broken into import, app construction and the first request
Serialization stacksWhich JSON encoder and validation path each framework actually ran
Not measuredThe two frameworks the in-process approach could not cover, and why

Deliberately absent: memory and RSS. They are out of scope for this comparison, so no memory figure appears anywhere on this page. This page reports throughput, latency and startup time, and nothing else.


How the numbers are produced

The harness lives in benches/. benches/bench.py is the entry point, benches/worker.py does one measurement, benches/workloads.py defines the requests and their expected answers, and benches/baseline_app.py is the framework-free floor.

One process per measurement

Every (framework, workload, repeat) runs in a freshly spawned interpreter with its own application and its own event loop. Nothing is shared between repeats or between frameworks.

That is not decoration. Several frameworks in this field patch shared classes at import time, and Sanic cannot stand up a second application in one interpreter at all — its touchup scheme compiles patched handler methods into a table keyed per app, and a second app in the same process leaves applications unable to start. An earlier revision of the harness built one fresh app per repeat inside a single process and watched Sanic fail on the second repeat. Rather than special-case one framework, every framework gets the same isolation.

The cost is real and is stated rather than hidden: one interpreter boot per repeat, which is why a full run takes about six minutes.

Rounds, not blocks

Repeats are interleaved round-robin: round r measures every framework and every workload before round r+1 starts. If the machine drifts — thermal throttling, a neighbour waking up — the drift lands on all participants instead of only on whoever was measured last.

The contract is re-checked in every timed process

The damaging outcome of a benchmark is not a wrong number, it is a fast wrong number. So each timed process re-checks its workload's status code and body against benches/workloads.py on the very application instance it is about to time, and the run aborts on a mismatch. Nothing here is timed that has not just answered correctly.

What each number means

NumberDefinition
Throughput (req/s)Completed requests ÷ elapsed wall time of one repeat
Latency p50/p95/p99 (ms)Per-request round trip as the in-process client observes it, pooled over a repeat, by linear interpolation
Spread (%)(max − min) / median × 100 over the per-repeat values of that statistic
Startup: process_msThe parent's wall clock around a whole startup subprocess — interpreter boot through process exit, so it is an upper bound on time to serve and carries each framework's teardown at the end
Startup: harness_importInside the child: process start → harness and framework imported
Startup: build_appApplication object constructed, routes registered
Startup: first_requestThe lifespan startup handshake plus the first real request, checked against the same hello contract the timed workloads use

Repeats use --warmup 0.25 (discarded) and --duration 0.5 (timed), one in-flight request at a time, and every statistic is the median of five repeats with its spread.

Warning: These are in-process numbers

httpx.ASGITransport calls the application directly: no socket, no server process, no TLS, no kernel scheduling. That is what makes the comparison repeatable and fair — every participant pays the same client cost, and no one gets a different server configuration — but it is not a deployed-configuration number. A uvicorn/gunicorn/granian deployment over a socket will move these numbers, and the ratio between frameworks can move with it. Read this page as "how much does the framework layer itself cost?", not as "what will my service do in production?".

The latency figures include the httpx client and the ASGI transport, not handler-only time, for the same reason: the client is common to everybody.


Reproducing this page

The pinned dependency set lives in benches/requirements-bench.txt, and one command prepares the environment and runs the harness:

bash
nox -s bench_frameworks

It creates benches/.venv with Python 3.13, installs the pins (re-running the install every time, so the pins are enforced even if the venv has drifted), runs the harness, and prints the path of the artifact it wrote. Arguments after -- go straight to benches/bench.py:

bash
nox -s bench_frameworks -- lauren fastapi          # measure a subset
nox -s bench_frameworks -- --repeat 1 --duration 0.1 --startup-repeats 0

The numbers on this page come from benches/results/frameworks-inprocess.json, committed next to the harness, which records the environment, the installed versions and a SHA-256 of every harness file it ran. If you only want to know whether every framework answers correctly, python benches/run.py re-runs the contract checks in seconds.


The machine and the dependency set

CPUIntel Core Processor (Broadwell, no TSX, IBRS), 8 logical cores
InterpreterCPython 3.13.13
OSLinux-7.0.0-14-generic-x86_64-with-glibc2.43 (x86_64)
Measured (UTC)2026-10-12T05:55:41+00:00
Harness revision2da3201 on feat/benchmark, working tree clean
Artifact schemalauren-framework-bench/1
ParticipantDistributionVersion
laurenlauren1.8.0.post17
fastapifastapi0.143.0
starlettestarlette1.7.0
litestarlitestar2.24.0
blacksheepblacksheep2.6.4
sanicsanic25.12.1
falconfalcon4.4.0
quartquart0.23.1
baseline—n/a — reference floor, not a framework

The pins matter to the numbers. Several frameworks switch JSON encoder on whichever fast library happens to be importable, so the same "framework version" can produce a different table on a different machine. The serialization table below records what each participant actually ran.


The five workloads

WorkloadRequestWhat it exercises
helloGET /Route lookup and response construction, nothing else — the floor.
jsonGET /jsonSerializing a nested, multi-typed payload.
path_queryGET /items/42?limit=10&q=abcExtracting and type-coercing one path and two query parameters.
diGET /diResolving a collaborator through each framework's own injection mechanism.
validationPOST /validate with {"name":"ada","age":36}Validating and coercing a request body, then serializing a derived model.

Throughput (requests per second)

Median of five repeats, higher is better. baseline is the hand-written ASGI callable with no framework in the path — the floor the harness itself can reach, included so you can tell framework cost from client cost. It is not a competitor, and its margin should not be read as one: the 1.13x–1.24x it holds over the best framework in this table is narrower than the spread on most of those cells, and re-running nox -s bench_frameworks has moved that margin by more than its own size — once putting the floor behind the best framework outright. Treat the floor as the reference point for what the client costs, not as a leaderboard entry.

Median throughput by workload, one bar per participant, with error bars spanning the min–max range across the repeats

hellojsonpath_querydivalidation
lauren2166.392134.531713.022191.761748.13
fastapi1955.181973.271484.261012.291519.4
starlette2426.362507.412201.632461.871940.94
litestar2175.452060.241980.912081.11596.75
blacksheep2457.042616.472237.052426.151701.08
sanic1994.731940.331889.622049.611558.08
falcon2302.552173.952343.972383.481974.44
quart1230.521267.881293.871291.31926.7
baseline2881.143093.452746.343061.962230.12

The spread tells you how much of a difference to believe. Median spread across the 45 cells is 19.4%, the tightest is 5.5% (FastAPI's validation) and the widest is 70.0% (Starlette's). Treat any gap smaller than the spread of the cells you are comparing as unresolved: on this machine that means most of the mid-field ordering, which is why this page reports the medians and their dispersion side by side rather than a ranking.

hellojsonpath_querydivalidation
lauren9.410.219.0518.743.74
fastapi18.7722.031.9730.695.48
starlette16.2312.817.7544.670.04
litestar15.2426.2822.1419.49.88
blacksheep13.5825.8634.9219.437.71
sanic14.435.9622.1529.621.5
falcon8.7822.1122.649.9124.15
quart33.015.9519.1813.022.92
baseline19.379.5120.3621.0120.61

Latency

Median of five repeats of the per-request round trip, in milliseconds, lower is better. The same nine participants, the same workloads.

One range does not fit the axis: Starlette's validation p99 reaches 10.50 ms, nearly four times the next-largest range in the artifact, so the latency error bars are capped at 2.7 ms. That range is cut off at the cap and carries its true maximum above a break marker, rather than flattening every other bar into the bottom of the chart.

p50, p95 and p99 round trip per participant, one panel per workload, with error bars spanning the min–max range across the repeats; colour marks the participant, and the one range past the 2.7 ms cap is cut off with its true maximum written above the break

p50

hellojsonpath_querydivalidation
lauren0.43360.41870.53410.41550.5332
fastapi0.47250.47070.60320.90930.5889
starlette0.35180.360.40720.33710.447
litestar0.41970.42790.46230.44180.5666
blacksheep0.34670.340.40690.37130.5276
sanic0.42180.43690.47160.41280.6002
falcon0.39690.41290.38630.37490.4569
quart0.72550.72110.72410.67581.0921
baseline0.29890.28940.32070.28710.4028

p95

hellojsonpath_querydivalidation
lauren0.64670.66340.79440.64180.792
fastapi0.68820.68950.92971.43610.8857
starlette0.5590.54360.63220.56890.7253
litestar0.62840.6540.69160.66050.8213
blacksheep0.5610.53610.59620.56550.7548
sanic0.66030.67910.70470.63050.8059
falcon0.57210.58440.57910.56080.6951
quart1.07310.97091.0361.02061.4227
baseline0.51250.47380.52220.46370.5975

p99

hellojsonpath_querydivalidation
lauren0.7320.73590.99710.72170.9518
fastapi0.87990.78661.19491.86230.9858
starlette0.80980.59680.81490.68081.2163
litestar0.72980.86720.73990.93581.0319
blacksheep0.75260.61770.74430.63341.0165
sanic1.53981.75941.74221.53232.1398
falcon1.0961.07591.1261.07341.2977
quart1.53281.48481.4771.50021.8719
baseline0.60740.53560.58080.53120.7106

A word on the tail: p99 spreads of 40–80% are normal here, because a single slow repeat moves the maximum. The extreme is Starlette's validation, whose p99 median is 1.22 ms across repeats but whose worst repeat hit 10.50 ms (spread 802%). That is the one range the latency figure cannot draw to scale — its error bars are capped at 2.7 ms and the bar carries its true maximum above a break marker. Read this column as an order of magnitude — "does the framework add a millisecond or ten?" — not as a ranking.


Startup

Five cold processes per participant: import, build the application, and get a first request answered. All millisecond values are medians of those five; spread is across them.

Participantprocess (ms)spreadimportbuild appfirst requestlifespan
lauren707.1427.84%318.3498.53.9yes
fastapi942.9214.94%301.91309.633.27yes
starlette619.0721.32%303.3634.381.94yes
litestar1146.0826.22%338.3381.7926.47yes
blacksheep708.919.69%306.69118.854.71yes
sanic861.7711.69%323.5165.6250.74yes
falcon617.1231.19%286.0767.593.67yes
quart883.1419.85%299.54245.155.39yes
baseline551.9640.3%285.70.03.06yes

process is what a user waits for: the parent measures the whole subprocess, so it includes interpreter boot and, at the end, the framework's teardown. The three phase columns come from inside the child and stop at the first answered request — they sum to child_total per sample, which excludes boot and teardown.

Note: Where the startup time actually goes

Every framework here spends 286–338 ms importing before it does anything else, and that term barely moves between them. The differences live in build_app and first_request: Starlette (34 ms) and Falcon (68 ms) wire themselves up fastest, FastAPI (310 ms), Quart (245 ms) and Litestar (382 ms) slowest, and Lauren's 98 ms is mid-field — its startup validation is a design trade (/docs/comparisons/Lauren vs FastAPI, Litestar & BlackSheep: a startup error is a CI failure, a runtime error is a 3 a.m. page), but this table does not show it as an outlier. The two real handshake costs are Litestar's and Sanic's first_request at 26 ms and 51 ms; everyone else answers their first request in under 6 ms.


Serialization stacks

Numbers are only comparable when the work is comparable. This table records the JSON path, the validation path and the injection mechanism each participant actually used in this run.

ParticipantJSON encoderValidationInjection
laurenstdlib json (lauren's StdlibJSONEncoder, the default)built-in Json[PydanticModel] extractorconstructor injection from the @module provider graph
fastapistdlib json (Starlette JSONResponse; ORJSONResponse is opt-in)built-in body annotation (pydantic)Depends(callable), resolved per request
starlettestdlib json (starlette.responses.JSONResponse)manual — await request.json() then model_validatenone — app.state lookup inside the handler
litestarmsgspec (msgspec.json.Encoder via litestar.serialization)built-in typed parameter (pydantic)Provide(dependency) declared on the route
blacksheepstdlib json (essentials.json.dumps, an optimised json.dumps)built-in pydantic binding on the handler parameterservices.add_singleton + handler-parameter resolution
sanicujson (sanic.helpers prefers ujson when it is installed)manual — request.json then model_validatenone — app.ctx lookup inside the handler
falconstdlib json (falcon.media.JSONHandler default)manual — await req.get_media() then model_validateconstructor injection at route-registration time
quartstdlib json (Quart's DefaultJSONProvider)manual — await request.get_json() then model_validatenone — app.extensions lookup inside the handler
baselinestdlib json (json.dumps)manual — json.loads + pydantic model_validatenone — the handler is a literal dict

Not measured: aiohttp and robyn

Both distributions are installed in the benchmark environment and both are listed in the artifact's not_measured section. Neither can be driven in-process, so neither has a number anywhere on this page.

aiohttp

Installed here as aiohttp 3.14.5. Entry point: none — aiohttp.web.Application is not an ASGI application.

aiohttp.web.Application exposes no ASGI callable: its call is a gunicorn-compatibility stub that takes no arguments and returns self, and the distribution ships no ASGI adapter. The only PyPI shims (aiohttp-asgi, aiohttp-asgi-connector) adapt in the opposite direction — they host an ASGI app on aiohttp's server — so they cannot put an aiohttp app behind ASGITransport. Hand-writing the bridge would measure the bridge, not aiohttp.

robyn

Installed here as robyn 0.88.0. Entry point: none — Robyn is a facade over a compiled Rust HTTP server.

Robyn's Python object configures a compiled Rust server that owns its own socket: the object exposes start()/serve(), defines no call, and the package ships no ASGI module (its submodules are router, ws, responses, ... but nothing ASGI). There is no in-process request path to drive, so no comparable number can be produced without measuring Robyn's own server against a socket, which is a different benchmark.

If you need a number for either, it has to come from a different benchmark: one that stands up the framework's own server and drives it over a socket. That measures the server too, which is a legitimate question — just not the one this page asks.


See also

  • Lauren vs FastAPI, Litestar & BlackSheep — the design comparison this page measures one axis of.
  • benches/ — the harness, the feasibility probe, the pinned requirements and the raw artifact this page is generated from.
  • Why Lauren? — the trade-offs the startup table above is a consequence of.