1.242 ASGI & WSGI Application Servers#
Python application servers measured with real sockets: uvicorn, gunicorn, granian, hypercorn, waitress, daphne, uWSGI. The 25x protocol gap, and three servers that drop requests on SIGTERM.
At a glance#
| Library | How it works | Best for | Latest release |
|---|---|---|---|
| uvicorn | ASGI, asyncio, pure wheel; optional compiled loop and parser | Almost any ASGI service, especially on a platform that supervises | 0.52.4 · 2026-08-19 |
| gunicorn | Pre-fork WSGI server and process manager; runs ASGI via a uvicorn worker class | WSGI on a VM, and anywhere supervision is genuinely needed | 26.2.0 · 2026-08-24 |
| granian | Rust runtime serving WSGI, ASGI and its own RSGI; manages its own workers | Container deployments wanting one server for both protocols and no supervisor | 2.8.2 · 2026-08-23 |
| hypercorn | ASGI on asyncio, uvloop or trio; HTTP/1.1, HTTP/2 and HTTP/3; pure wheel | HTTP/3 or trio at the application server — and nothing else here offers either | 0.18.0 · 2025-11-08 |
| waitress | Pure-Python WSGI, zero dependencies, one process and a thread pool | Windows, and anywhere nothing may be compiled | 3.0.2 · 2024-11-16 |
| daphne | ASGI on Twisted; HTTP/1.1, HTTP/2, WebSockets | Django Channels, because Channels documents it | 4.2.3 · 2026-07-21 |
| uWSGI | C, WSGI plus its own binary protocol; enormous feature surface | Where it is already running and working | 2.0.31 · 2025-10-11 |
| uvloop | libuv replacement for asyncio’s event loop; platform wheel | Any uvicorn deployment whose target has a wheel | 0.22.1 · 2025-10-16 |
| httptools | llhttp (Node’s parser) replacement for uvicorn’s pure-Python parser | As uvloop | 0.8.0 · 2026-05-25 |
| cheroot / socketify / mod_wsgi | CherryPy’s extracted server; a uWebSockets binding; an Apache module | Narrow cases — Apache shops, and the waitress alternative | 11.1.2 · 2025-11-07 |
| meinheld / bjoern | Greenlet and libev WSGI servers, both C extensions | Nothing — recorded because they are still recommended | 1.0.2 · 2020-05-17 |
Latest release observed from PyPI in 2026-09.
What the research found
- The protocol gap is 25x, and the server does not get to choose it — uvicorn serves 32,400 requests a second where gunicorn’s sync worker serves 1,300 — same machine, same 23-byte response, one worker each. Every other difference measured here moves throughput by tens of percent. And 1.241 established the choice is not the server’s: the framework decides which protocol you speak and the server can only implement it.
- Two thirds of ‘uvicorn is fast’ belongs to uvloop and httptools, and only under load — 1.04x at concurrency 8; 2.81x at concurrency 128. The pure-Python configuration does not merely go slower, it stops scaling — about 13,000 rps and declining against 35,000. Both are
uvicorn; which one you have depends on whether someone typed[standard]and whether a platform wheel existed. uvloop does not support Windows at all, so uvicorn there is permanently the slower one, and nothing at install or startup says so. - Three of nine server configurations drop an in-flight request on SIGTERM, at their defaults — hypercorn, daphne and waitress close the connection within ten milliseconds; the other six wait 1.25 seconds for the handler and answer. This is the one measurement that changes what a deployment DOES rather than how fast it is — every rolling deploy, autoscaler scale-in and pod eviction sends SIGTERM to a process that is probably mid-request. It does not trade against speed: granian drops nothing and is among the fastest, waitress drops and is among the slowest.
- Worker counts do opposite things on the two protocols — WSGI gains 7x from eight workers — gunicorn sync goes 967 -> 6,765 rps with p50 falling 62ms -> 7.9ms — and the default is one, so a service left on it runs at an eighth of the machine. One ASGI worker starts 20x ABOVE where eight WSGI workers finish. A team carrying the –workers habit across from WSGI is turning a CPU-parallelism dial and expecting a concurrency dial.
- hypercorn saturates at concurrency 8 — 7,258 rps at c=8, 7,869 at c=32, 6,860 at c=128, while p50 latency goes 1.0ms -> 3.8ms -> 17.1ms. It is saturated at c=8 and everything after is queueing. A benchmark run at one concurrency level would report it as half uvicorn’s speed; across a range it is a fifth, and the shape is the finding rather than the ratio.
throughput rps one worker
- concurrency 8: gunicorn+uvicorn-worker 13822, uvicorn 12755, granian 11363, hypercorn 7258, granian-wsgi 6242, daphne 4245, waitress 2501, gunicorn-gthread 1392, gunicorn-sync 1379
- concurrency 128: gunicorn+uvicorn-worker 36381, uvicorn 32437, hypercorn 6860, granian-wsgi 6401, daphne 6362, waitress 2584, gunicorn-gthread 1399, gunicorn-sync 1312, granian EXCLUDED — client-bound at 0.91 of a core
uvicorn accelerators speedup
- concurrency 1: 1.24
- concurrency 8: 1.04
- concurrency 32: 2.5
- concurrency 128: 2.81
sigterm during request
- survives: uvicorn; granian; granian-wsgi; gunicorn-uvicorn-worker; gunicorn-sync; gunicorn-gthread
- drops: hypercorn; daphne; waitress
worker scaling to 8
- gunicorn-sync: 6.99x (967 -> 6,765 rps, p50 62ms -> 7.9ms)
- gunicorn-gthread: 3.66x
- asgi servers: UNMEASURED — every multi-worker ASGI run saturated the client
Explainer
Domain Explainer: Python Application Servers#
What sits between the network and your code, why it is a separate choice from the framework, and the handful of things about it that actually change an outcome. Terms are defined where they first appear.
The division of labor#
A web request arrives at a machine as bytes on a network socket. Something has to:
- Own the socket — hold the port open, accept connections, read the bytes.
- Understand HTTP — find where one request ends, split the address from the headers.
- Find the right piece of your code and run it.
- Turn the answer back into bytes and write them out.
In Python, steps 1, 2 and 4 belong to the application server. Step 3 belongs to the framework. They are two separate programs from two separate projects, and you choose them separately.
Survey 1.241 is about the framework — Flask, FastAPI, Django. This one is about the server — uvicorn, gunicorn, granian. The dividing line is the socket: whoever owns it is the server.
Why they are separate at all#
Because a specification sits between them. The server speaks it on one side, the framework on the other, and neither needs to know which implementation is on the far end.
That is why you can run Flask under gunicorn or waitress without changing Flask, and why uvicorn can serve FastAPI, Starlette or Litestar without knowing which it is.
There are two such specifications, and the split between them is the largest single fact in both surveys.
| year | the contract | |
|---|---|---|
| WSGI | 2003 | one function call, start to finish, one request at a time |
| ASGI | 2018 | an async function, with the request arriving as messages |
WSGI is synchronous: the server calls your code, waits for it to return, and that worker is busy for the whole time. ASGI is asynchronous: your code can pause partway through — while waiting for a database — and the server does something else meanwhile.
Measured here, that difference is 25×: 32,400 requests a second against 1,300, on the same machine answering the same 23 bytes.
You do not choose it. Your framework does. Flask and Django are WSGI; FastAPI and Starlette are ASGI; the server can only implement whichever one you already have.
Workers, and the thing that gets them backwards#
A worker is a copy of your application the server runs. Most servers can run several.
Under WSGI, a worker handles one request at a time. Two workers, two at a time. This is the only way to get concurrency, so the worker count is the whole story — and the default is one. Measured: going from one worker to eight took a WSGI server from 967 requests a second to 6,765, a 7× gain, and dropped its typical response time from 62 milliseconds to 8.
Under ASGI, one worker already juggles hundreds of requests, because the event loop interleaves them whenever one is waiting. Measured: one ASGI worker started 20× above where eight WSGI workers finished. Adding workers there buys you more CPU cores, not more concurrency.
So the same flag means different things on either side of the line, and a team moving from one to the other tends to keep turning the dial that no longer does anything.
The two things most likely to bite you#
Ten characters that are worth 2.8×#
uvicorn, the most common ASGI server, can use two optional compiled components — a faster
event loop and a faster HTTP parser. They arrive if you install uvicorn[standard] and not
if you install uvicorn.
Without them uvicorn still works. It just goes slower, silently, and no message says so.
Measured: at low traffic the difference is 4% — small enough that a quick test would say they do not matter. Under load it is 2.8×, and the version without them stops improving altogether while the version with them keeps going.
They also do not exist on Windows, which makes uvicorn there permanently the slower one.
Being told to stop, mid-request#
When a deployment updates, an autoscaler shrinks, or a machine is reclaimed, the system sends the server a polite request to shut down (SIGTERM). At that moment the server is usually part-way through answering somebody.
There are two possible behaviors: finish the request and then exit, or close the connection immediately. The second gives that user an error.
Measured across nine configurations: six finish the request. Three do not — they close within a hundredth of a second. This is the setting most worth checking, because it is invisible until someone notices errors that only happen during deploys, and because it does not trade off against anything: one of the fastest servers measured handles it correctly and one of the slowest does not.
What comes in the box, and what does not#
Servers differ in how much they do beyond serving:
- Process management — restarting a worker that dies, reloading without dropping traffic. gunicorn does this thoroughly; uvicorn does very little of it. That is not a defect: on a container platform, the platform already restarts things, and a server doing it too is a duplicated layer. On a plain virtual machine, nothing else will.
- Protocol reach — all of them do HTTP/1.1. A couple add HTTP/2. One adds HTTP/3, which in practice is usually handled by whatever sits in front.
- How it installs. Some are pure Python and install anywhere. Some ship compiled binaries and need one built for your machine. One ships no binary at all and compiles on install, which fails on the small container images most deployments now use.
What to take away#
- The framework picks the specification; the server implements it. That single fork is worth more than every other difference here combined.
- The worker count is everything on WSGI and nearly nothing on ASGI. Same flag, opposite meaning.
- Check what happens on shutdown. Three of nine drop the request, and it is silent.
- On ASGI, install the accelerated variant unless your platform cannot take it.
- Speed is rarely the deciding factor. For most services the server is fast enough by a wide margin, and the decisions that matter are whether something restarts a dead worker, whether the thing installs on your image, and whether a deploy drops requests.
S1: Rapid Discovery
uvloop and httptools#
Not servers. Two drop-in replacements that change what a server does, and the reason a uvicorn benchmark may not be measuring uvicorn.
What they replace#
| replaces | with | wheel | |
|---|---|---|---|
| uvloop | asyncio’s event loop | libuv, the loop Node.js runs on | platform |
| httptools | uvicorn’s pure-Python HTTP parser | llhttp, Node’s parser | platform |
Both are installed by pip install uvicorn[standard]. Neither is installed by
pip install uvicorn.
Why they matter to this survey#
uvicorn works correctly without them. It detects what is present at startup, uses the faster implementation when it can, and falls back silently when it cannot. Nothing errors, nothing warns, and the only symptom is that the server is slower.
So “uvicorn is fast” is a claim about a configuration, not about a package — and which configuration you have depends on whether someone typed four extra characters, and on whether a platform wheel existed for your target when they did.
Measuring that split is L5 of the plan, and it is the number most often misattributed in this category.
Measured position#
uvloop: 276 million installs a month. httptools: 272 million. Against uvicorn’s 676 million, that is roughly 40%.
The gap is the population running uvicorn without its speedups. Nothing in the registry says whether that is deliberate — a deployment that cannot take platform wheels — or simply the default install nobody revisited.
uvloop last released 2025-10-16 and httptools 2026-05-25. Both are platform-wheel only, both predate uvicorn’s current maintainer, and uvloop does not support Windows at all, which quietly makes “uvicorn on Windows” a different and slower server than “uvicorn on Linux”.
The consequence for reading anyone’s benchmark#
A uvicorn number is uninterpretable without knowing whether uvloop and httptools were present. Most published figures do not say. This survey’s own L3 will state it for every run, and L5 measures the difference so that the size of the omission is on the record.
Also in the category#
Five servers that are part of the field without being live candidates. Each is here for a stated reason; two of them are dead and the fact that they are still recommended is the finding.
mod_wsgi#
What it is. A WSGI implementation that runs inside Apache httpd rather than standing
alone, as an Apache module plus a mod_wsgi-express command.
Why it matters. For an organization already running Apache — and there are many, mostly outside the startup world where these comparisons are usually written — it is a real answer that puts the Python application under the same process supervision, TLS termination and access control as everything else on the host. It is invisible in these comparisons because it is invisible in download counts: the people who need it install it from the OS package manager.
Measured. Version 6.0.6 on PyPI, released 2026-08-19, sdist only — it compiles against Apache’s headers, so it needs those plus a toolchain. Python ≥3.10.
The trade. Apache-shaped deployments only, and no ASGI.
cheroot#
What it is. The HTTP server extracted from CherryPy, now a standalone pure-Python WSGI server with a thread pool.
Why it matters. It is the closest thing to a direct alternative to waitress — pure Python, dependency-light, thread-pool concurrency — and its release cadence is currently better: 11.1.2 on 2025-11-07 against waitress’s 2024-11-16.
Measured. 202 stars, pure wheel, Python ≥3.8. The star count is the problem: this is a maintained project almost nobody is choosing.
socketify.py#
What it is. A Python server built on µWebSockets, the C++ library behind uWebSockets.js, speaking both ASGI and WSGI.
Why it matters. It is the other attempt at granian’s idea — a fast native core under a Python protocol adapter — and comparing their trajectories is informative. granian’s first release was 2022 and it now has 16.7 million installs a month; socketify’s last release was 2024-10-29 and it is still at 0.0.31.
The trade. Two projects tried the same thing at the same time. One is shipping and one has stopped, and nothing about the idea explains the difference.
bjoern#
What it is. A WSGI server in C, built on libev, historically one of the fastest things you could put a Python application behind.
Measured. Version 3.2.2, released 2022-09-11 — no release in four years. Sdist only.
Why it is here. It still appears in benchmark round-ups and “fastest WSGI server” posts that have not been revised. A reader who follows one of those lands on a project that has shipped nothing since Python 3.10 was current.
meinheld#
What it is. A WSGI server on greenlets, the other half of the “fastest WSGI server” pairing with bjoern, and for years the recommended gunicorn worker class for high-throughput deployments.
Measured. Version 1.0.2, released 2020-05-17. Six years.
Why it is here. gunicorn --worker-class meinheld is still in circulation as advice. The
package installs, compiles if it can, and is unmaintained. This and bjoern are the two clearest
cases in the category of a recommendation outliving the thing it recommends.
What is compared here#
Eleven servers, plus the two accelerators that change what a server does. Each file states the same things in the same order — what it is, which protocol it speaks, its process model, how it is packaged, its measured position, and what you give up.
What counts as an application server#
A program that owns the listening socket, speaks HTTP to the network, and hands each request to an application object it did not write. Three parts, and all three matter:
- It owns the socket. This is the line against survey 1.241, which stops at the application object. A framework routes a request it has already been given; a server is what gave it.
- It speaks a Python application protocol — WSGI, ASGI, or RSGI. The protocol is the contract between the two halves, and which one a server speaks decides which frameworks it can run at all.
- It runs somebody else’s application. A framework that ships its own server (Sanic, aiohttp, Tornado, Robyn — see 1.241) is out of scope as a server, because the pair is not separable and there is no decision to make.
The three protocols#
| year | shape | frameworks | |
|---|---|---|---|
| WSGI | 2003 | one callable, synchronous, one request per worker | Flask, Django, Bottle, Pyramid |
| ASGI | 2018 | async callable, scope + receive + send, HTTP and WebSockets | FastAPI, Starlette, Litestar, Quart |
| RSGI | 2023 | granian’s own, async, designed against a Rust runtime | anything granian adapts |
RSGI is included because granian speaks it natively and it is the only place in this category where a server proposes a new contract rather than implementing an existing one. Whether that is worth anything is S4’s question.
What a server actually decides#
Four things, and they are the axes every file below is written against:
- Concurrency model — threads, processes, an event loop, or a mix. This is the whole decision for a WSGI deployment and most of it for an ASGI one.
- Process management — who restarts a dead worker, who handles a reload, who owns the socket across a deploy. Some servers do this and some expect systemd or a container scheduler to.
- Protocol reach — HTTP/1.1 everywhere, HTTP/2 and HTTP/3 in one place, WebSockets in most ASGI servers and none of the WSGI ones.
- What it costs to install — a pure wheel, a platform wheel, or a C compiler.
Boundary cases, decided#
- gunicorn is a WSGI server and a process manager that runs uvicorn workers. Both halves are judged, because the second is how most production ASGI deployments actually run and it is frequently described as though gunicorn were doing the serving.
- uvloop and httptools are not servers. They are drop-in replacements for the event
loop and the HTTP parser, they are what
uvicorn[standard]installs, and leaving them out would misrepresent what uvicorn is when deployed normally. - mod_wsgi runs inside Apache rather than standing alone. Included briefly, because for a shop already running Apache it is a real answer and it is invisible in download counts.
- daphne is Django Channels’ server. Included: it is a general ASGI server and the Django ecosystem’s default answer for WebSockets.
Out of scope#
- Reverse proxies and load balancers — nginx, Caddy, Traefik. They sit in front of an application server and solve a different problem.
- Managed platforms that hide the server entirely.
- The framework’s own dispatch cost — measured in 1.241 and not repeated here.
daphne#
What it is#
The ASGI server from the Django Channels project, and the reference implementation the ASGI specification was written alongside. It runs on Twisted rather than asyncio directly.
Protocol and process model#
ASGI, with HTTP/1.1, HTTP/2 and WebSockets. Twisted underneath, which is the oldest async framework in Python and predates asyncio by a decade.
Single process; scaling is by running several behind a proxy. No built-in supervision.
Its position is institutional rather than technical: Django Channels documents daphne, and a Django application adding WebSockets arrives here by following its own framework’s documentation rather than by comparing servers.
Measured position#
5.9 million installs a month — the lowest of the maintained ASGI servers. 2,700 stars, which is more than hypercorn’s on fewer installs. Pure wheel, Python ≥3.9.
Version 4.2.3, released 2026-07-21, and it is now under the django/ organization rather
than an individual. That is the opposite direction of travel from uvicorn and Starlette, and
S4 should say so: daphne is the one server here that moved toward an institution.
Trade-offs#
You get the Django ecosystem’s blessed path, WebSockets that Channels documents against, HTTP/2, and foundation-backed maintenance.
You give up performance relative to uvicorn — Twisted’s HTTP stack is not what recent work has optimized — and you take on Twisted as a dependency, which is a large and unfamiliar codebase for most Python developers in 2026.
Where it is weak: outside Django. There is little reason to choose daphne for a FastAPI or Litestar application, and its install base reflects that.
granian#
What it is#
An HTTP server written in Rust that runs Python applications, speaking WSGI, ASGI and its own RSGI from one binary. It is the only server in this survey that offers all three, and the only recent entrant with real adoption.
Protocol and process model#
Three protocols, and the third is the argument:
- WSGI and ASGI for compatibility with everything already written.
- RSGI, granian’s own application protocol, designed against a Rust runtime rather than retrofitted onto asyncio.
Workers are processes, with threads inside them, managed by granian itself — so unlike uvicorn it does not need a separate supervisor for restarts and reload. HTTP/1.1 and HTTP/2, which uvicorn does not do at all.
The packaging fact that decides most of it#
granian ships platform wheels, not a pure one. That is inherent — it is a Rust binary — and it is the trade at the center of choosing it:
| uvicorn | granian | |
|---|---|---|
| bare install | pure Python, runs anywhere | a wheel must exist for your target |
| fast install | pure + uvloop + httptools (both platform) | the same binary, no options |
The comparison is narrower than it looks. A production uvicorn install already pulls two platform wheels, so on any target where granian has a wheel and uvicorn’s speedups do too, the packaging difference is smaller than the pure-versus-compiled framing suggests. Where it bites is the target that has neither.
Measured position#
16.7 million installs a month — more than hypercorn (7.9M) and waitress (8.6M) combined. 5,573 stars. Version 2.8.2, released 2026-08-23. Python ≥3.10.
That ordering is the finding. The established alternatives to uvicorn have been available for years; granian’s first release was 2022, and it has passed both. Against uvicorn’s 676 million it is a rounding error, but uvicorn’s number is inflated by being FastAPI’s dependency, and granian’s is not inflated by anything.
It reached 2.x, which puts it ahead of uvicorn and hypercorn on version commitment despite being the youngest project here.
Trade-offs#
You get one server for WSGI and ASGI, HTTP/2, built-in worker management, and a Rust runtime rather than CPython’s for the parts that are not your application.
You give up the pure-Python install and accept a platform wheel. You give up the documentation gravity — a problem hit under uvicorn has been hit publicly by thousands of people, and under granian it may not have been. And RSGI, the protocol that is granian’s distinguishing idea, is supported by one server, which is the definition of a contract with one implementation.
Where it is weak: as a choice you have to defend. The technical case is real, the adoption is growing faster than any alternative here, and the ecosystem is still uvicorn’s.
gunicorn#
What it is#
A pre-fork WSGI server and process manager. A master process owns the listening socket, forks N workers, restarts them when they die, and cycles them on a reload signal. Each worker handles requests according to a worker class, and the worker class is where the real decision lives.
Seventeen years old, and the thing most Python web applications have actually been deployed on.
Protocol and process model#
WSGI natively. It also runs ASGI applications — by loading uvicorn.workers.UvicornWorker
as its worker class, which starts a uvicorn event loop inside each forked process.
That arrangement is the most common production ASGI deployment, and it is frequently described as “running on gunicorn”:
gunicorn supervises; uvicorn serves. The HTTP parsing, the event loop and the ASGI dispatch are uvicorn’s. What gunicorn contributes is the master process — forking, restarting a worker that dies, graceful reload, and holding the socket across a deploy.
Worker classes, and what each is for:
| class | model | for |
|---|---|---|
sync | one request per worker, blocking | the default; CPU-bound or fast handlers |
gthread | a thread pool per worker | blocking I/O without an event loop |
gevent / eventlet | greenlets, monkey-patched | legacy async on WSGI |
uvicorn.workers.UvicornWorker | an asyncio loop per worker | ASGI applications |
Measured position#
139 million installs a month — second in this survey, and a fifth of uvicorn’s, which understates its deployment share for the reason 1.241 gave about Django: gunicorn is installed to deploy something, while uvicorn is additionally pulled in as a dependency of tooling and of FastAPI itself.
10,700 stars — within 300 of uvicorn’s, on a project twice its age. Version 26.2.0, released 2026-08-24, five days before this measurement. Pure wheel, Python ≥3.10.
It reached 26.x. Unlike almost everything else in this category and its neighbour, this is a project with a real major-version history and a stability record behind it.
Trade-offs#
You get the most complete process model in the category: supervision, graceful reload, worker timeouts, and a socket that survives a deploy. You get seventeen years of production hardening and a configuration surface that operations teams already know. You get one process manager that runs both your WSGI and your ASGI applications, which matters when the estate has both.
You give up speaking ASGI yourself — the ASGI path is a worker class wrapping another
server, so you have two projects in the request path and two sets of release notes. You give
up HTTP/2. And the sync default is a trap for an I/O-bound application: it is one request
per worker, and it is what you get if you do not choose.
Where it is weak: as the ASGI answer on its own. It contributes no ASGI implementation, and a deployment that does not need supervision — a container scheduler already restarts a dead process — is paying for a layer whose job something else is doing.
hypercorn#
What it is#
An ASGI server, written by the author of Quart, and the only server in this category that speaks HTTP/1.1, HTTP/2 and HTTP/3. It runs on asyncio, uvloop or trio — the only one here offering a choice of async runtime.
Protocol and process model#
ASGI, with WebSockets over both HTTP/1.1 and HTTP/2. Workers are processes or threads; supervision is thinner than gunicorn’s and comparable to uvicorn’s.
HTTP/3 support is real and rarely load-bearing. It requires QUIC over UDP, which most deployments terminate at a reverse proxy or CDN long before the application server sees it. The capability is genuine; the number of deployments where the application server is the thing that needs it is small.
Trio support is the more interesting option and the less used one. Trio is a different concurrency model from asyncio, and an application written against it cannot run on uvicorn at all.
Measured position#
7.9 million installs a month — the lowest of the actively maintained ASGI servers, below granian’s 16.7 million and two orders of magnitude below uvicorn. 1,600 stars.
Version 0.18.0, released 2025-11-08: nine months before this measurement, the longest gap of any ASGI server here that is still maintained. Pure wheel, Python ≥3.10, still 0.x after eight years.
The trajectory is the finding. hypercorn was the established alternative to uvicorn for years; granian arrived in 2022 and now has more than twice its installs.
Trade-offs#
You get HTTP/2 and HTTP/3 from a pure-Python install, a choice of async runtime including trio, and the server most closely aligned with Quart.
You give up momentum. A nine-month release gap and an install base being overtaken by a three-year-old project are not signs of a project in trouble, but they are signs of one that is no longer the default anyone reaches for.
Where it is weak: as a general choice. Its distinguishing features — HTTP/3, trio — are each decisive for a small population and irrelevant otherwise, and for the common case uvicorn has the ecosystem and granian has the trajectory.
Observed data#
Every number in S1 comes from this table. Measured, not recalled.
Measured: 2026-08-28
Sources: PyPI JSON API (version, license, Python floor, release dates, wheel kind);
pypistats.org /recent (last-month downloads); GitHub REST repos/<owner>/<repo>.
| server | protocol | version | downloads/mo | stars | wheels | Python floor | last release |
|---|---|---|---|---|---|---|---|
| uvicorn | ASGI | 0.52.4 | 676,235,674 | 10,933 | pure | ≥3.10 | 2026-08-19 |
| gunicorn | WSGI (+ASGI via workers) | 26.2.0 | 139,361,178 | 10,662 | pure | ≥3.10 | 2026-08-24 |
| granian | ASGI + WSGI + RSGI | 2.8.2 | 16,727,452 | 5,573 | platform | ≥3.10 | 2026-08-23 |
| waitress | WSGI | 3.0.2 | 8,592,943 | 1,598 | pure | ≥3.9 | 2024-11-16 |
| hypercorn | ASGI (+HTTP/2, HTTP/3) | 0.18.0 | 7,890,689 | 1,601 | pure | ≥3.10 | 2025-11-08 |
| daphne | ASGI | 4.2.3 | 5,921,014 | 2,679 | pure | ≥3.9 | 2026-07-21 |
| uWSGI | WSGI | 2.0.31 | 2,000,106 | 3,544 | sdist only | — | 2025-10-11 |
| cheroot | WSGI | 11.1.2 | — | 202 | pure | ≥3.8 | 2025-11-07 |
| socketify | ASGI + WSGI | 0.0.31 | — | 1,713 | platform | ≥3.8 | 2024-10-29 |
| bjoern | WSGI | 3.2.2 | — | — | sdist | — | 2022-09-11 |
| meinheld | WSGI | 1.0.2 | — | — | sdist | — | 2020-05-17 |
Accelerators — not servers, but they change what a server does:
| version | downloads/mo | wheels | what it replaces | |
|---|---|---|---|---|
| uvloop | 0.22.1 | 275,635,723 | platform | asyncio’s event loop |
| httptools | 0.8.0 | 271,595,517 | platform | uvicorn’s HTTP parser |
On precision#
The table records what was measured. Prose rounds it — “676 million”, not 676,235,674. A monthly download count moves daily, counts mirrors and CI, and depends on where the month boundary falls; nine significant figures imply a stability it does not have. Stars go to three figures, ratios to two. The exact values stay here with their date and source, because that is what makes the measurement re-checkable.
What the download numbers mean here#
Less than in most categories, and for a specific reason. uvicorn’s 676 million is almost exactly Starlette’s (676 million, survey 1.241) because a FastAPI install pulls both. It measures how hard uvicorn is to avoid, not how often anyone weighed it against gunicorn.
The number that carries information is the ratio between alternatives, where nothing forces the install: granian at 16.7 million against hypercorn’s 7.9 and waitress’s 8.6.
uvloop and httptools are installed 276 and 272 million times a month — roughly 40% of uvicorn’s total. They are optional. That gap is the size of the population running uvicorn without the speedups it ships with, and it is large.
Wheel kind decides where a server can run#
| consequence | |
|---|---|
| pure (uvicorn, gunicorn, hypercorn, waitress, daphne) | installs anywhere Python runs |
| platform (granian, uvloop, httptools, socketify) | a wheel must exist for your target, or you build it |
| sdist only (uWSGI, bjoern, meinheld) | compiles at install: a C toolchain on every machine that installs it |
uWSGI shipping no wheels at all is the single most consequential packaging fact in this
category, and it is why a pip install uwsgi fails on a slim container image that has no
compiler.
Two are dead and one has stopped#
meinheld: last release 2020-05-17. bjoern: 2022-09-11. Both were once standard answers for “fastest WSGI server” and neither has shipped in the lifetime of any current Python.
waitress last released 2024-11-16 — 21 months before this measurement, the longest gap among servers still in use, on 8.6 million installs a month.
Everything else shipped within the last ten months, and four within the last two weeks.
S1 verdict#
Five findings survive the reading pass. The measurements that would settle the rest are in
../S2-comprehensive/measurement-plan.md.
1. The foundation under FastAPI has one maintainer#
encode/uvicorn resolves to Kludex/uvicorn — parent: null, source: null, on a
repository created 2017-05-31. A transfer, not a fork, confirmed the same way 1.241
confirmed Starlette’s.
So Marcelo Trylesinski maintains both the ASGI toolkit under FastAPI and the server that runs it. FastAPI itself sits in its own organization under a different maintainer.
1.241 called Starlette’s move “a viability question, not a trivium” on the strength of one
library. It is two, they are adjacent layers, and between them they carry 676 million
installs a month each. Neither package’s metadata mentions the transfer, and documentation
predating it still points at encode.
daphne went the other way in the same period — it now sits under the django/ organization.
It is the only server here that moved toward an institution.
2. Two of the three fastest-growing facts are about packaging, not speed#
uWSGI ships no wheels. Sdist only, so every install compiles C, so it fails on a slim image without a build stage. It is the oldest and most capable server here and the hardest to install.
granian ships platform wheels only. Inherent to being a Rust binary, and the usual framing — pure Python versus compiled — is narrower than it looks, because a production uvicorn already pulls two platform wheels of its own.
The third fact is that everything else here is a pure wheel, which is why uvicorn, gunicorn, waitress, hypercorn and daphne install anywhere and the other two do not.
3. “uvicorn is fast” is a claim about a configuration#
uvloop and httptools are what uvicorn[standard] adds and what uvicorn does not. uvicorn
runs correctly without them, silently, and slower.
They are installed at roughly 40% of uvicorn’s rate — 276 and 272 million against 676 million. So a large share of running uvicorns are the slow configuration, and no published benchmark this survey found states which one it measured.
uvloop also does not support Windows, which makes uvicorn-on-Windows a materially different server from uvicorn-on-Linux without either being labeled differently.
4. granian has passed the established alternatives, and it is three years old#
16.7 million installs a month against hypercorn’s 7.9 and waitress’s 8.6 — more than both together. It is also at 2.x while uvicorn is at 0.52 after nine years and hypercorn at 0.18 after eight.
Against uvicorn’s 676 million it is negligible, but uvicorn’s number is inflated by being FastAPI’s dependency and granian’s is not inflated by anything. Among servers somebody chose on purpose, the ordering is different from the one the totals suggest.
5. Two dead servers are still being recommended#
meinheld last released 2020-05-17; bjoern 2022-09-11. Both were standard answers to
“the fastest WSGI server”, and gunicorn --worker-class meinheld is still in circulation as
advice. Both still install. Neither has shipped in the lifetime of any current Python.
waitress is the ambiguous one: 21 months without a release on 8.6 million installs a month. WSGI is a frozen 2003 specification and a correct implementation of it does not need annual releases, so the gap reads differently here than it would on a framework. S4 has to weigh that rather than wave it through.
Open questions#
Four things this pass could not settle by reading, all of which need a socket:
- Throughput and latency, one framework held constant across every server. Every published number in this category varies the framework and the server together.
- Where each process model stops scaling — the WSGI worker count, the ASGI event loop,
and gunicorn’s
syncdefault, which is what you get if you do not choose. - How much of uvicorn’s performance is uvloop and httptools.
- Whether graceful shutdown works — every server claims it; the claims mean different things and an in-flight request either survives SIGTERM or it does not.
What this pass does not conclude#
No server is recommended here. Packaging facts, release gaps and ownership are recorded as category facts; what they mean depends on who is deploying, which is S3.
uvicorn#
What it is#
An ASGI server built on asyncio. It owns the socket, parses HTTP, and calls the application’s ASGI callable. It is the default in FastAPI’s documentation, Starlette’s, and Litestar’s, and it is what the category’s tutorials tell you to run.
Protocol and process model#
ASGI only. HTTP/1.1 and WebSockets; no HTTP/2, no WSGI. A Flask application does not run under uvicorn without a WSGI-to-ASGI adapter, and the adapter reintroduces the blocking worker that ASGI exists to avoid.
One process, one event loop, by default. --workers N forks N independent processes
sharing a socket, with no shared state and no supervision beyond restarting on exit. That
is a thin process model by design, and it is why gunicorn is so often placed in front of
it.
What it is made of, and what is optional#
uvicorn installs pure Python. uvicorn[standard] adds:
| replaces | wheel | |
|---|---|---|
| uvloop | asyncio’s event loop, with a libuv implementation | platform |
| httptools | the pure-Python HTTP parser, with Node’s llhttp | platform |
| websockets / wsproto | the WebSocket implementation | pure |
| watchfiles, PyYAML, colorama | reload, config, terminal colors | mixed |
This split is the most consequential thing about uvicorn and the least visible. The bare install is pure Python and runs anywhere; the fast install pulls two compiled extensions. Whether the machine you are deploying to has wheels for them is a real question on ARM, on Alpine, and on anything unusual — and the fallback is silent. uvicorn runs correctly without them and simply goes slower.
The download figures put uvloop and httptools at roughly 40% of uvicorn’s own installs. The other 60% is either deliberate or unaware; nothing in the registry distinguishes those.
Measured position#
676 million installs a month — the most-installed package in this survey by a factor of five, and effectively identical to Starlette’s figure in 1.241, because a FastAPI install pulls both. 10,900 stars. Version 0.52.4, released 2026-08-19. Python ≥3.10. Pure wheel.
Still 0.x after nine years, which places it with FastAPI in the group whose version number
promises nothing about stability.
The repository moved, and it moved to the same person as Starlette#
encode/uvicorn now resolves to Kludex/uvicorn — parent: null, source: null, on a
repository created 2017-05-31. Those fields identify a transfer, not a fork.
1.241 found the same thing for Starlette. Taken together: Marcelo Trylesinski now maintains both the ASGI toolkit under FastAPI and the server that runs it. FastAPI itself remains in its own organization under a different maintainer.
S4 takes up what that concentration means. For S1 the fact is that the two load-bearing layers beneath the most popular Python API stack have the same single point of failure, and neither package’s metadata says so.
Trade-offs#
You get the category default, the widest documentation and community, a pure-Python install that works anywhere, and compiled speedups available when the platform allows.
You give up HTTP/2 and HTTP/3 entirely. You give up process supervision — restarts, graceful reload and worker lifecycle are somebody else’s job, usually gunicorn’s or a container scheduler’s. You give up the 1.0 stability promise. And you accept that the performance most benchmarks attribute to uvicorn is partly uvloop’s and httptools’, which you may or may not have installed.
Where it is weak: as a complete production answer on its own. The common deployment is uvicorn workers under gunicorn, because uvicorn’s own supervision is minimal.
uWSGI#
What it is#
A large, old, C-implemented application server that speaks WSGI among many other things. It
was the standard production answer for Python web applications for most of the 2010s, and it
does much more than serve HTTP: process management, a caching framework, a cron subsystem,
queues, and its own uwsgi binary protocol for talking to nginx.
Protocol and process model#
WSGI natively, plus its own binary protocol. Processes, threads, or both; a master process with extensive supervision; and a configuration surface larger than every other server in this survey combined.
No ASGI. For an async Python application, uWSGI is not a candidate.
The packaging fact#
It ships no wheels — sdist only. pip install uwsgi compiles C on the machine doing the
install, every time, which means a compiler, Python headers and libc development packages on
that machine.
This is the single most consequential packaging fact in the category. On a slim container image — the normal case in 2026 — the install fails, and the fix is either a build stage or a fatter image. Every other server here installs from a wheel.
Measured position#
2.0 million installs a month, the lowest of any server still maintained here, and 1/70th of gunicorn’s. 3,544 stars — more than granian, hypercorn, waitress or daphne, which is a record of how widely it was used.
Version 2.0.31, released 2025-10-11. Still on the 2.0 line, which has been the current line since 2013.
The gap between its star count and its install count is the clearest decay signal in this survey: a large audience that came, and a small one that stayed.
Trade-offs#
You get an enormously capable server with two decades of production use, deep nginx integration, and features no other option here has.
You give up the ability to install it without a toolchain, any ASGI future, and a configuration surface anyone can hold in their head. Its documentation is famously large and famously hard to navigate.
Where it is weak: new work. Everything it is good at, something more focused now does more simply, and the sdist-only packaging makes it actively awkward in the deployment style most teams now use.
waitress#
What it is#
A pure-Python WSGI server from the Pylons project, with no dependencies at all. It is the answer to “I need a production-quality WSGI server that installs anywhere, including on Windows, with nothing to compile.”
Protocol and process model#
WSGI only. No ASGI, no WebSockets, no HTTP/2. A thread pool inside a single process: requests are handed to worker threads, and the pool size is the concurrency limit.
Single-process by design. There is no fork, no master, no worker supervision — that is systemd’s job, or a container scheduler’s. For a small deployment that is one less moving part; for a multi-core machine it means one process is not using the other cores.
It is the WSGI server that works on Windows, which is not a small thing: gunicorn does not support Windows at all, and for a Windows-hosted Python web application waitress and mod_wsgi are most of the field.
Measured position#
8.6 million installs a month, 1,600 stars, pure wheel, Python ≥3.9.
Version 3.0.2, released 2024-11-16 — 21 months before this measurement, the longest gap of any server here still in real use.
That gap deserves care rather than alarm. waitress is small, dependency-free, and does one thing that has not changed: WSGI is a frozen specification from 2003 and a correct implementation of it does not need annual releases. A 21-month gap on a project like Flask would read differently than it does here.
But it is still the longest gap in the category, on a project with a thread pool as its whole concurrency story, and S4 has to weigh it rather than wave it through.
Trade-offs#
You get zero dependencies, a pure wheel, Windows support, and a server small enough to read. Nothing to compile and nothing to configure beyond the thread count.
You give up ASGI, WebSockets, HTTP/2, and multi-process use of the machine. The thread pool is the ceiling and it is a hard one: a slow handler occupies a thread for its whole life, exactly as WSGI requires.
Where it is weak: anything I/O-bound at scale, and anything needing WebSockets. It is a good answer to a narrow question and it does not pretend otherwise.
S2: Comprehensive
How these servers were measured#
Four levels, run on 2026-08-28 against real sockets. The recipe, the pinned versions, the
harness and the raw per-run output are in bench/, and every figure below can be
re-run from them.
| level | what it measured |
|---|---|
| L3 | throughput and latency, the server varying, one application held constant |
| L5 | uvicorn with and without uvloop and httptools |
| L2 | startup, and whether SIGTERM drops a request already in flight |
| L4 | what a worker count buys, per protocol |
The workload#
GET /items/42 returning 23 bytes — the same route and response as survey 1.241’s bench, so
the two sit on one axis. Starlette for ASGI and Flask for WSGI, both chosen for being thin:
a heavier framework adds its own cost to every server equally, which shrinks the differences
this survey exists to show.
Five seconds per run after a warm-up, keep-alive connections, concurrency 1 / 8 / 32 / 128. The figure reported is requests per second completed, with p50/p95/p99 latency alongside.
One axis at a time#
For every level, the application, the route, the payload, the client and the machine are fixed and only the named axis moves. L3 varies the server. L5 varies uvicorn’s loop and parser. L4 varies the worker count.
A number produced by moving two things at once measures the pair. That is the defect that made the published benchmarks in this category unusable and the reason S1 quoted none of them: “server X is faster than server Y” is almost always two different frameworks as well.
The load generator, and when its numbers are thrown away#
Raw asyncio sockets with a pre-built request. httpx or requests would add per-request
client cost comparable to what is being measured.
Client and server share eight cores, so a saturated generator would be measuring itself.
Every run records the generator’s own CPU against wall time, and a run using more than 85% of
a core is marked client_bound and excluded. Eleven of 56 runs were excluded on that
basis, each named in its results file — including one that had already been quoted in this
survey’s working notes and is now withdrawn.
The practical consequence: this rig has a ceiling around 35,000–40,000 requests a second. Below it the numbers are the server’s; above it they are not, and a second machine is what would settle those cases.
Reading the numbers#
aarch64, CPython 3.12.3, WSL2 on a laptop, 8 cores.
granian is a Rust binary and uvloop and httptools are compiled extensions; everything else is interpreted CPython. A compiled-against-interpreted ratio is built differently for different instruction sets, so these figures compare with each other and not with numbers measured on x86_64.
Not measured#
HTTP/2 and HTTP/3, TLS, anything on Windows, anything above the rig’s ceiling, and any deployment across two machines. Each is named where it changes a conclusion rather than being left implied.
S2 measurement plan#
Written before S2, per Step 3.5. Levels ordered by cost. The default cut line is everything that runs in one container with no external service and no second machine — and this survey raises it, for the reason below.
Why the cut line moves for this survey#
A framework can be measured without a server: 1.241 drove the application callable directly and that was the right scope. A server cannot be measured without a socket. Owning the socket is the definition of the thing. Every question worth asking here — throughput, concurrency behavior, what happens at the worker limit — requires a listening port and a load generator.
So the load generator is not deferred work in this survey. It is the survey.
It still runs in one container with no external service and no second machine, so the rest of the cut line holds.
The rig is shared with 1.241#
1.241 deferred two levels because they needed exactly this apparatus (re-3xp.7):
- L5 — throughput under concurrent load, holding the server constant while the framework varies. Closes 1.241’s open question 1.
- L6 — Django’s async ORM against a real database. Closes 1.241’s open question 3.
This survey needs the same rig with the axes transposed: hold the framework constant while the server varies. One harness, two surveys, and the second one is free.
Building it here and running 1.241’s axis at the same time is the reason this survey was taken next.
The levels#
| level | settles | cost | rung | covers | |
|---|---|---|---|---|---|
| L0 | Registry facts | versions, wheels, floors, cadence, ownership | done in S1 | cited | 11 |
| L1 | Install cost — wheel kind, transitive deps, whether a C toolchain is needed on a slim image | uWSGI’s sdist-only packaging; what uvicorn[standard] actually pulls | ~1 h | measured-local | 11 |
| L2 | Startup and shutdown — time to first accepted connection, and whether an in-flight request survives SIGTERM | graceful-shutdown claims, which are made widely and mean different things | ~2 h | measured-local | 7 |
| L3 | Throughput and latency under load, one framework held constant across every server | the survey’s central question, and 1.241’s open question 1 on the transposed axis | ~1 day | measured-local | 7 |
| L4 | The concurrency cliff — where each process model stops scaling: WSGI worker count, ASGI event loop, gunicorn’s sync default | the sync-worker trap; what a worker count actually buys | ~4 h | measured-local | 7 |
| L5 | uvloop and httptools on and off | how much of “uvicorn is fast” belongs to uvicorn | ~2 h | measured-local | uvicorn |
| L6 | Django’s async ORM against a real Postgres | 1.241’s open question 3 | ~1 day | measured-local | Django |
| L7 | HTTP/2 behavior, granian and hypercorn | whether HTTP/2 changes anything at this layer | ~4 h | measured-local | 2 |
| L8 | Floor model | the reader can check me | a build | measured-browser | none |
The cut#
S2 takes L1 through L5. One container, a load generator, no external service. L3 and L4 are the survey; L5 is the number most often misattributed.
L6 is taken as well, out of order and against the usual cut, because it closes 1.241’s question 3 and the container is already standing. It is the only level here needing an external service.
L7 is deferred unless HTTP/2 turns out to change the L3 ranking.
L8 is impossible here#
There will be no floor model for this survey. A floor model runs in the reader’s browser, and no browser tab has a listening socket. A server that cannot listen cannot be demonstrated; there is nothing to put on a Workshop page.
1.241’s page exists because a framework can be driven without a socket. That is exactly the boundary between the two surveys, and it shows up here as the reason one of them can have a floor model and the other never will.
The evidence ladder tops out at measured-local for this category. Per
docs/map/17-the-evidence-ladder.md, that is the ceiling, not a failure — and the
recipe in bench/ is what makes it count.
Two rules the numbers depend on#
Hold everything but the axis constant. For L3 the framework, the handler, the payload, the client and the machine are fixed and only the server changes. For 1.241’s L5 the server is fixed and only the framework changes. A number produced by varying both measures the pair, which is the defect that made every published benchmark in these two categories unusable.
Report the client too. A load generator has its own ceiling, and a measurement where the generator saturated before the server did is a measurement of the generator. The rig records client CPU alongside every run, and any run where the client was the bottleneck is discarded rather than reported.
S2 verdict#
Five findings, and one retraction.
1. The protocol gap is 25×, and the server does not get to choose it#
uvicorn serves 32,400 requests a second where gunicorn’s sync worker serves 1,300 —
same machine, same 23-byte response, one worker each.
Every other difference measured here is tens of percent. This one is more than an order of magnitude, and 1.241 established that it is not the server’s decision: the framework decides which protocol you speak, and the server can only implement it.
2. Two thirds of “uvicorn is fast” is uvloop and httptools, and only under load#
| concurrency | speedup from the accelerators |
|---|---|
| 8 | 1.04× |
| 128 | 2.81× |
At the concurrency a laptop benchmark uses, they are worth 4% and a reader would conclude they do not matter. At production concurrency the pure-Python configuration stops scaling entirely — 13,000 rps and declining, against 35,000.
Both are uvicorn. Which one you have depends on whether someone typed [standard] and
whether a platform wheel existed for the target. uvloop does not support Windows at all,
so uvicorn there is always the slower one.
3. Three of nine servers drop an in-flight request on SIGTERM#
hypercorn, daphne and waitress close the connection within ten milliseconds. The other six wait for the handler and send the response.
This is the finding that changes what a deployment does rather than how fast it is. Every rolling deploy, autoscaler scale-in and pod eviction sends SIGTERM to a process that is probably mid-request. On three of these servers, at their defaults, that is a reset for whoever was waiting.
It does not trade off against speed: granian drops nothing and is among the fastest; waitress drops and is among the slowest.
4. Worker counts do the opposite thing on each protocol#
WSGI gains 7× from eight workers (gunicorn sync: 967 → 6,765 rps, p50 62 ms → 7.9 ms).
The worker count is the entire concurrency story and the default is one.
One ASGI worker starts 20× above where eight WSGI workers finish — 32,000 rps against 6,765 — because the event loop interleaves in-process.
A team carrying the --workers habit across from WSGI is turning a CPU-parallelism dial and
expecting a concurrency dial. Both numbers above are real and they point in opposite
directions.
5. hypercorn saturates at concurrency 8#
7,258 rps at c=8 and 6,860 at c=128, with p50 latency going 1.0 ms → 17.1 ms. Everything past c=8 is queueing.
A single-concurrency benchmark would call it half uvicorn’s speed. Across a range it is a fifth, and the shape matters more than either ratio.
The retraction#
granian’s high-concurrency throughput is not measured by this survey. An earlier working note claimed it overtakes uvicorn by 25-30% at concurrency 128, on a run where the load generator used 0.91 of a core — over the threshold that marks a run as measuring the client rather than the server. That run is excluded and the claim is withdrawn.
On the runs that are clean, uvicorn is ahead of granian at every concurrency measured: 2,048 to 1,623 at c=1, 12,755 to 11,363 at c=8, 31,856 to 27,104 at c=32.
What can be said is that granian pushed the load generator harder, which is consistent with it being faster and is not evidence of it. Settling this needs a second machine.
What this rig cannot answer#
Its ceiling is roughly 35,000-40,000 requests a second, above which the client saturates and no server number is trustworthy. Client and server share eight cores. Eleven of the 56 runs are excluded on that basis and each is named in its results file.
Not measured at all: HTTP/2 and HTTP/3, TLS, multi-machine, and anything above the ceiling.
How to read every number here#
aarch64, CPython 3.12.3, WSL2 on a laptop, 8 cores. granian is a Rust binary and uvloop and httptools are compiled; the rest is interpreted CPython, and a compiled-against-interpreted ratio is built differently per target. These figures compare with each other and not with numbers measured on x86_64.
Recipe, pinned versions, harness and raw per-run output: bench/.
L2 — startup, and what SIGTERM does to a request in flight#
Every server here claims graceful shutdown. The claims mean different things, and the question a deployment has is narrow: when the orchestrator sends SIGTERM during a request, does that request get its response, or does the client get a reset?
Method: a handler that sleeps 1.5 seconds, one request in flight, SIGTERM at 250 ms, then
wait to see whether the response arrives. Raw: bench/results/l2.json.
| server | startup | in-flight request on SIGTERM |
|---|---|---|
| uvicorn | 0.31 s | survived (1.25 s) |
| granian | 0.31 s | survived (1.26 s) |
| granian (wsgi) | 0.30 s | survived (1.26 s) |
| gunicorn + uvicorn worker | 0.30 s | survived (1.25 s) |
| gunicorn sync | 0.30 s | survived (1.25 s) |
| gunicorn gthread | 0.31 s | survived (1.26 s) |
| hypercorn | 0.30 s | dropped (0.01 s) |
| daphne | 0.61 s | dropped (0.01 s) |
| waitress | 0.30 s | dropped (0.00 s) |
Three of nine drop the request#
hypercorn, daphne and waitress close the connection within ten milliseconds of SIGTERM. The client gets a reset; the work is lost.
The other six wait for the handler to finish — 1.25 seconds, the remaining life of the request — and then send the response before exiting.
This is a binary property with a direct operational consequence, and it is the one measurement in this survey that changes what a deployment does rather than how fast it is: every rolling deploy, every autoscaler scale-in and every pod eviction sends SIGTERM to a process that is probably mid-request. On three of these servers that is a 502 for whoever was waiting.
It is not a hidden cost of a fast server, either. granian drops nothing and is among the fastest measured; waitress drops the request and is among the slowest.
What this does not say#
The three that drop are not misconfigured here — they were run with default flags, which is the point, because defaults are what most deployments run. Two of them expose shutdown timeouts that were not set, and setting them may change the result. What is measured is the default, and a server whose default is to drop the request is a server that will drop requests until somebody discovers the flag.
Startup#
Every server accepts its first connection within a third of a second, except daphne at 0.61. Nothing here separates them: at these times, startup cost is not a decision input, and a container image pull dwarfs all of it.
L3 — throughput and latency, the server varies#
One application held constant — Starlette for ASGI, Flask for WSGI, GET /items/42
returning 23 bytes. Five seconds per run after a warm-up, keep-alive, concurrency 1 / 8 /
32 / 128, one worker. aarch64, 8 cores, client and server sharing the machine.
Raw: bench/results/l3.json.
One of 36 runs is excluded as client-bound. It is named below rather than dropped quietly.
ASGI#
| server | c=1 | c=8 | c=32 | c=128 |
|---|---|---|---|---|
| gunicorn + uvicorn worker | 2,125 | 13,822 | 35,554 | 36,381 |
| uvicorn | 2,048 | 12,755 | 31,856 | 32,437 |
| granian | 1,623 | 11,363 | 27,104 | excluded |
| hypercorn | 1,033 | 7,258 | 7,869 | 6,860 |
| daphne | 1,131 | 4,245 | 6,008 | 6,362 |
WSGI#
| server | c=1 | c=8 | c=32 | c=128 |
|---|---|---|---|---|
| granian (wsgi) | 1,553 | 6,242 | 6,703 | 6,401 |
| waitress | 1,294 | 2,501 | 2,633 | 2,584 |
| gunicorn gthread | 983 | 1,392 | 1,314 | 1,399 |
| gunicorn sync | 595 | 1,379 | 1,273 | 1,312 |
The protocol gap is the decision#
uvicorn serves 32,400 requests a second where gunicorn’s sync worker serves 1,300. A
25× difference, on the same machine, answering the same 23 bytes, with one worker each.
Nothing else measured here comes close to mattering as much. The choice between two ASGI servers moves throughput by tens of percent; the choice between ASGI and WSGI moves it by more than an order of magnitude — and 1.241 established that the choice is not really the server’s to make, because the framework decides which protocol you are speaking.
The WSGI servers do not scale with concurrency, by construction#
Every WSGI row is flat from c=8 onward, and two of them are flat from c=1. gunicorn sync
serves ~1,300 requests a second whether 8 clients are asking or 128; what changes is the
latency, from 5 ms at c=8 to 94 ms at c=128.
That is WSGI working as specified. One worker handles one request at a time, so added concurrency becomes queue depth rather than throughput. The fix is more workers, which L4 measures, and it is the only fix available.
hypercorn stops scaling past c=8#
7,258 rps at c=8, 7,869 at c=32, 6,860 at c=128 — while its p50 latency goes from 1.0 ms to 3.8 ms to 17.1 ms. It is saturated at c=8 and everything after that is queueing.
A benchmark run at a single concurrency level would report hypercorn as roughly half uvicorn’s speed. Run across a range, it is a fifth, and the shape is the finding rather than the ratio.
gunicorn’s sync worker opens a TCP connection per request#
conns_per_request is 1.0 for gunicorn sync and 0.001 for everything else. The sync
worker does no HTTP keep-alive: it closes after every response, so each request pays a fresh
connection setup.
This was found by the load generator failing. Its first version treated the close as an
error and reported four requests and four errors; counting the reconnects instead turned the
server’s behavior into a column. Part of gunicorn sync’s 25× disadvantage is this, and not
the worker model.
The excluded run, and this rig’s ceiling#
granian at c=128 is excluded. It returned 41,402 rps with the load generator using 0.91 of a core, over the 0.85 threshold that marks a run as measuring the client. uvicorn at the same concurrency used 0.76 and is reported.
Two things follow, and the second matters more than the first:
- granian’s high-concurrency throughput is not measured here. An earlier version of this survey’s working notes claimed granian overtakes uvicorn by 25-30% at c=128 on the strength of that run. That claim is withdrawn: it rests on a measurement of the load generator.
- This rig has a ceiling of roughly 35,000-40,000 rps, above which the client saturates and no server number is trustworthy. Everything at or under that is reported; above it, this apparatus cannot answer, and a second machine is what would settle it.
On the runs that are clean, uvicorn is ahead of granian at every concurrency level measured — 2,048 to 1,623 at c=1, 12,755 to 11,363 at c=8, 31,856 to 27,104 at c=32. The question of what happens above that is open.
gunicorn with a uvicorn worker beats bare uvicorn#
36,381 against 32,437 at c=128, and ahead at every level. Both are running the same uvicorn code to serve the request.
The difference is not the serving; it is what surrounds it. This survey has not isolated the cause and does not guess at one — it is a consistent 6-12% across four concurrency levels and worth knowing, because the usual framing treats the gunicorn wrapper as pure overhead.
L4 — what a worker count buys#
1.241 found FastAPI’s thread pool holds 40 and the 41st request doubles group latency. The
question one layer down is what --workers does, and the answer is opposite for the two
protocols.
Concurrency held at 64, workers 1 / 2 / 4 / 8 on an 8-core machine.
Raw: bench/results/l4.json.
Ten of twenty runs are excluded as client-bound, and the pattern of which ones is itself the finding. See below.
WSGI scales with workers, nearly linearly#
| server | w=1 | w=2 | w=4 | w=8 | scaling |
|---|---|---|---|---|---|
| gunicorn sync | 967 | 2,435 | 3,769 | 6,765* | 1.0 → 2.5 → 3.9 → 7.0× |
| gunicorn gthread | 1,512 | 3,149 | 5,147 | 5,527 | 1.0 → 2.1 → 3.4 → 3.7× |
* client-bound at w=8.
gunicorn sync at one worker serves 967 requests a second with a p50 of 62 ms; at eight
it serves 6,765 with a p50 of 7.9 ms. That is close to linear, and it is what the model
predicts: one worker is one request at a time, so N workers is N requests at a time.
For a WSGI deployment the worker count is the whole concurrency story, and the default is one. A service left on the default is running at an eighth of what the same machine would give it.
gthread starts higher — a thread pool inside each worker adds concurrency without a second
process — and flattens sooner, gaining almost nothing from the fourth to the eighth worker.
ASGI barely scales with workers, and the reason is the point#
| server | w=1 | w=2 | w=4 | w=8 |
|---|---|---|---|---|
| uvicorn | 32,699 | 46,014* | 47,950* | 44,282* |
| granian | 33,827 | 41,312* | 41,205* | 37,963* |
| gunicorn + uvicorn worker | 31,936 | 42,977* | 42,192* | 40,017* |
* every multi-worker ASGI run is client-bound and is not a server measurement.
What can be said from the clean column: one ASGI worker serves 32,000-34,000 requests a second, which is 20-34× what one WSGI worker manages, because an event loop interleaves thousands of in-flight requests inside a single process instead of one at a time.
What cannot be said is how ASGI scales beyond one worker. Every w≥2 run saturated the load generator, which is exactly what the guard exists to catch. The apparent 1.2-1.5× is a measurement of the client giving up.
That is a finding rather than a gap. A single ASGI worker on this machine already pushes past what the load generator on the same machine can drive. For most services, the first question about ASGI worker counts is not what they buy but whether anything downstream can supply enough load to notice.
The inversion#
A team moving from WSGI to ASGI carries over a habit that no longer applies. Under WSGI,
--workers is the concurrency dial and turning it up is the fix. Under ASGI it is a
CPU-parallelism dial, and the concurrency was already handled by the event loop.
Both numbers here are real: WSGI gains 7× from eight workers, and one ASGI worker starts 20× above where eight WSGI workers finish.
L5 — how much of uvicorn’s speed is uvloop and httptools#
uvicorn selects a faster event loop and HTTP parser when they are importable and falls back
silently when they are not. Both are what uvicorn[standard] adds and plain uvicorn does
not; both are platform wheels; and S1 measured them at roughly 40% of uvicorn’s own install
rate.
--loop and --http were passed explicitly rather than left to autodetection, so each run
records its configuration instead of inheriting it. Raw: bench/results/l5.json.
| concurrency | uvloop + httptools | asyncio + h11 | speedup |
|---|---|---|---|
| 1 | 1,434 | 1,160 | 1.24× |
| 8 | 11,137 | 10,711 | 1.04× |
| 32 | 32,601 | 13,041 | 2.50× |
| 128 | 35,149 | 12,501 | 2.81× |
The accelerators do almost nothing until they do everything#
At concurrency 8 the difference is 4%. At concurrency 128 it is 2.8×.
The pure-Python configuration does not merely go slower under load — it stops scaling. It reaches about 13,000 requests a second at c=32 and then declines, while the accelerated configuration continues to 35,000. Its p50 latency at c=128 is 10.1 ms against 3.5 ms.
So the answer to “how much of uvicorn’s performance belongs to uvicorn” depends entirely on where you measure. At the concurrency a laptop benchmark uses, almost all of it. At the concurrency a production service sees, roughly two thirds of it belongs to uvloop and httptools.
What this does to published numbers#
A uvicorn figure is uninterpretable without knowing which configuration produced it, and the two differ by 2.8× in the range that matters. This survey found no published benchmark that states which one it measured.
It also explains a shape that shows up in reports of “uvicorn was slower than expected in
production”: a bare pip install uvicorn, on a target with no wheels for the compiled
extensions, is a materially different server from the one in the documentation.
uvloop does not support Windows. uvicorn on Windows is therefore always the slower configuration, and nothing in its packaging or startup says so.
The measurement to take from this#
At low concurrency the accelerators are worth 4%, and a benchmark run there will tell you they do not matter. That reading does not survive contact with load, and the gap opens between c=8 and c=32 — which is inside the range where most services actually operate.
S3: Need-Driven
The seven situations#
| the situation | what eliminates options | lands on | |
|---|---|---|---|
| 1 | An ASGI service on a container platform | the scheduler already supervises | uvicorn, alone |
| 2 | A WSGI application on a VM | nothing else restarts a dead worker | gunicorn, workers ≥ cores |
| 3 | Anything that gets rolling-deployed | SIGTERM lands mid-request, often | not hypercorn, daphne or waitress |
| 4 | A slim or unusual image | no compiler, maybe no matching wheel | uvicorn bare, or gunicorn |
| 5 | Django with WebSockets | Channels documents one answer | daphne, with a caveat that matters |
| 6 | Python on Windows | gunicorn does not run; uvloop does not exist | waitress or uvicorn, both degraded |
| 7 | One person hosting their own things | attention, not throughput | uvicorn or granian, by taste |
The verdict sets out where these conflict.
What decides a server, in order#
Measured across the four levels, the things that actually change an outcome rank like this:
- Which protocol your framework speaks. Not a choice — 1.241 made it — and worth 25×.
- Whether SIGTERM drops the request. Binary, and three of nine get it wrong by default.
- Whether
[standard]was typed, on ASGI. Worth 2.8× under load. - The worker count, on WSGI. Worth 7×, and the default is one.
- Which server, among those speaking your protocol. Tens of percent.
The list is upside down relative to how the choice is usually discussed. The last item is the one with benchmarks written about it.
S3 verdict#
The grid#
| situation | lands on | |
|---|---|---|
| 1 | ASGI on a container platform | uvicorn [standard], one worker, no gunicorn |
| 2 | WSGI on a VM | gunicorn, workers ≥ cores |
| 3 | Anything rolling-deployed | not hypercorn, daphne or waitress |
| 4 | Slim or unusual image | bare uvicorn, or gunicorn for WSGI |
| 5 | Django + WebSockets | daphne, with its shutdown treated as a defect |
| 6 | Windows | waitress or uvicorn, both degraded |
| 7 | Self-hosting one person | uvicorn, probably without [standard] |
Two places these conflict#
Situation 3 against situation 5. Channels documents daphne; daphne drops in-flight requests on SIGTERM. A Django shop that rolling-deploys is pointed at that server by its own framework and away from it by the measurement, and no reading of the evidence dissolves that. The resolution is a configuration change or an informed acceptance, not a different server.
Situation 1 against situation 4. Both reach for uvicorn and disagree about [standard].
The container platform wants it — 2.81× under load. The constrained image may not be able to
have it, because uvloop and httptools are platform wheels. Same server, opposite advice, and
the difference is what the target can install.
What the measurements changed#
Across seven situations, L3’s throughput ranking changed one recommendation — situation 5’s, and there it lost to a shutdown measurement rather than to speed.
The two that changed the most were not performance numbers in the usual sense:
L2, the SIGTERM result, eliminates three servers from a property most deployments have, and it cuts across every other situation.
L5, the accelerator gap, is a ten-character configuration change worth 2.8× that nothing warns you about. For situation 1 it is the entire performance decision.
And L4 reframes a habit: --workers is worth 7× on WSGI and close to nothing on ASGI. A
team carrying that dial across protocols is turning it for no reason.
Who this category serves badly#
- Anyone needing HTTP/3 at the application server. hypercorn offers it; hypercorn also saturates at concurrency 8 and has not released in nine months. The combination is not available.
- Windows deployments. gunicorn does not run, uvloop does not exist, and the remaining options are a 21-month-old release and a permanently degraded uvicorn. This survey did not measure on Windows, and the platform is under-served.
- Anyone wanting one server for both protocols with pure-Python packaging. granian does both protocols and needs a platform wheel; everything pure does one protocol. The combination does not exist.
Situation 4 — A slim or unusual image#
Who#
A deployment where the runtime is not a stock python:3.12 on x86_64: a distroless or
Alpine image, an ARM host, an embedded target, a build pipeline that forbids compilers, or
anywhere a wheel might simply not exist.
What eliminates options#
No C toolchain, and possibly no matching wheel. Both halves matter, and they eliminate different servers.
What the measurements say#
S1’s packaging data decides this before any performance number is reached:
| consequence | |
|---|---|
| uvicorn, gunicorn, hypercorn, waitress, daphne — pure wheels | install anywhere Python runs |
| granian, uvloop, httptools — platform wheels | a wheel must exist for the target, or you build |
| uWSGI — sdist only | compiles on every install: needs a compiler, headers, libc dev |
uWSGI is eliminated outright. pip install uwsgi on a slim image fails, and the fix is a
build stage or a fatter image.
granian is eliminated where no wheel exists for the architecture and libc — musl on Alpine is the usual gap. Where a wheel does exist, and in a container it usually does since the image targets one platform, the objection disappears.
The subtle one: uvicorn[standard] is not pure. uvloop and httptools are platform
wheels, so a persona that reached for uvicorn on packaging grounds and then typed
[standard] has quietly taken the same dependency it was avoiding — and L5 says that is
worth 2.8× under load, so it is a real trade rather than a mistake.
Where it lands#
Bare uvicorn for ASGI, accepting the accelerator loss, or [standard] where the target
turns out to have wheels — which is worth checking rather than assuming.
gunicorn for WSGI: pure wheel, no compiler, and the process management this deployment shape usually also needs.
Not uWSGI, whatever its other merits.
Situation 1 — An ASGI service on a container platform#
Who#
A FastAPI, Starlette or Litestar service running on Kubernetes, ECS, Cloud Run, Fly or a similar scheduler. The platform restarts containers that exit, holds the load balancer in front, and scales by adding replicas.
What eliminates options#
The supervision is already somebody else’s. The scheduler restarts a dead process, handles rollout, and owns the socket at the load-balancer level. A server that contributes its own process management is contributing a layer the platform duplicates.
That removes the main argument for gunicorn. It also means worker counts are usually the wrong dial — replicas are the dial, and L4 showed ASGI gains little from workers anyway.
What the measurements say#
Type [standard], or accept a third of the throughput. L5: 2.81× at concurrency 128,
and the pure-Python configuration stops scaling around 13,000 rps. This is the single
highest-value thing this persona can act on, it costs ten characters, and nothing warns when
it is missing.
L3 puts uvicorn at 32,400 rps on one worker — comfortably past what most services in this shape need, and past what this survey’s own load generator could drive.
L2: uvicorn keeps an in-flight request through SIGTERM, which a scheduler sends on every scale-in.
Where it lands#
uvicorn, with [standard], one worker per container, no gunicorn. The ecosystem is
uvicorn’s, the supervision is the platform’s, and the accelerators are the whole performance
question.
granian is a defensible alternative — it drops nothing on SIGTERM, manages workers itself, and is within tens of percent on the clean measurements. Its cost is a platform wheel, which in a container is a non-issue since the image is built for one target.
Not gunicorn here. Its contribution is supervision, and the platform is already doing it.
Situation 5 — Django with WebSockets#
Who#
A Django application adding real-time features — notifications, a live dashboard, chat — through Django Channels.
What eliminates options#
WSGI cannot carry WebSockets. That is the protocol, not an implementation gap, so the moment Channels enters, the WSGI server the application has been running on stops being sufficient.
And Channels documents daphne. A team following its own framework’s documentation arrives at one answer without comparing servers.
What the measurements say#
Following the documentation lands on the slowest ASGI server measured and one of the three that drops in-flight requests on SIGTERM.
L3: daphne serves 4,245 rps at concurrency 8 against uvicorn’s 12,755 — a third. It is on Twisted, whose HTTP stack has not been the target of recent optimization work.
L2: daphne closes within ten milliseconds of SIGTERM. Every Django deploy therefore drops whatever was in flight.
Neither of those is a reason to abandon Channels, and the throughput number is probably not the binding constraint for a persona whose traffic is WebSocket connections rather than request throughput. The SIGTERM behavior is a real problem and it is not obvious from anything Channels says.
Where it lands#
daphne, with the shutdown behavior treated as a known defect to be configured around or accepted — better known before the first deploy than after the first support ticket.
uvicorn is a supported alternative for Channels and is faster and safer on shutdown. It is less documented for this path, which is exactly the trade S1 kept finding: the documented answer and the better-measured answer are different servers.
This is where situation 3 and situation 5 conflict. A Django-plus-Channels shop that also rolling-deploys is being pointed at daphne by its framework and away from it by the shutdown measurement, and no reading of the evidence dissolves that.
Situation 3 — Anything that gets rolling-deployed#
Who#
Any service that is redeployed without a maintenance window: a rolling update, a blue-green cutover, an autoscaler scaling in, a spot instance being reclaimed, a pod evicted for a node drain.
This is not a separate kind of service. It is a property almost every deployed service has, and it cuts across the other six situations.
What eliminates options#
SIGTERM arrives while requests are in flight, routinely. A service handling any real traffic is mid-request at the moment it is told to stop, several times per deploy.
The requirement is narrow and binary: the in-flight request must get its response.
What the measurements say#
L2 measured exactly this — 1.5-second handler, SIGTERM at 250 ms:
| survives | drops |
|---|---|
| uvicorn, granian, granian-wsgi, gunicorn (sync, gthread, uvicorn-worker) | hypercorn, daphne, waitress |
The six that survive wait the remaining 1.25 seconds and answer. The three that drop close the connection within ten milliseconds and the client gets a reset.
Two things make this the most actionable finding in the survey:
It does not trade against speed. granian drops nothing and is among the fastest measured; waitress drops and is among the slowest. Nobody is buying performance with these failures.
It is invisible until it is a support ticket. The symptom is a scatter of 502s correlated with deploys, which is dismissed as “the load balancer” more often than it is traced to the server’s shutdown behavior.
Where it lands#
Not hypercorn, daphne or waitress at their defaults. That is the whole recommendation, and it eliminates servers that other situations would otherwise choose — including situation 5’s answer, which is a real conflict rather than a tidy one.
Two of the three expose shutdown-timeout settings that were not set here, and setting them may change the result. What was measured is the default, and the default is what runs until somebody finds the flag.
Anything on the left column is fine, and the choice reverts to whichever other situation applies.
Situation 7 — One person hosting their own things#
Who#
An individual or a very small team running the services their own work depends on: an internal bot, a dashboard, a link shortener, an MCP server. They deploy it, they maintain it, and they are the only on-call.
What eliminates options#
Attention, not throughput. Nothing here will see the concurrency at which any of these servers differ. The binding constraint is how much the deployment can be left alone, and how quickly a problem can be diagnosed at eleven at night.
What the measurements say#
Almost nothing in L3 applies. The gap between the fastest and slowest ASGI server measured is 32,400 against 4,245 requests a second, and this persona’s traffic is closer to four requests a minute. Choosing on throughput here is choosing on the wrong axis.
L2 does apply, mildly. A service restarted by systemd or a small container host still gets SIGTERM, and a dropped request is a confusing failure for someone with no other instrumentation.
L5 applies in an unexpected direction: uvicorn[standard] pulls two platform wheels, and
for a self-hoster on an ARM board or an unusual base image that is one more thing that can
fail to install. The accelerators buy nothing at this traffic and can cost an evening.
Where it lands#
uvicorn, and probably without [standard] — the ecosystem is the reason, and it is the
right reason here. When this persona is stuck, the size of the answered-questions corpus is
the support contract, and uvicorn’s is the largest by a wide margin.
granian if the deployment is a container on a normal architecture and self-managed workers are attractive. It drops nothing on SIGTERM and needs no supervisor.
gunicorn for a WSGI application on a VM, for situation 2’s reason: something must restart the worker and nothing else here will.
Not hypercorn, daphne, waitress or uWSGI. Each fails this persona for a different reason — shutdown behavior, a Django-only brief, a 21-month release gap, a compiler requirement — and none of them offers this reader anything the alternatives do not.
Situation 6 — Python on Windows#
Who#
A Python web application that must run on Windows: an internal tool on a Windows server, a desktop-adjacent application, a shop whose infrastructure is Windows and whose Python is a guest.
What eliminates options#
Two hard exclusions, neither of them a performance judgment:
gunicorn does not run on Windows. It is a pre-fork server and depends on fork. This
removes the most complete process model in the survey and, with it, the usual WSGI answer.
uvloop does not support Windows. So uvicorn[standard] installs but the accelerator is
absent, and L5 puts that at 2.81× under load. uvicorn on Windows is always the slower
configuration, and nothing at install or startup says so.
What the measurements say#
The measurements were taken on Linux and the ranking does not transfer intact, which is said plainly rather than glossed: this survey did not measure any server on Windows.
What does transfer is the structural facts. uvicorn without uvloop stops scaling around 13,000 rps on this hardware (L5), and that configuration is the only one Windows can have. waitress’s thread-pool model is unaffected by the platform.
waitress drops in-flight requests on SIGTERM (L2), though Windows service shutdown is a different mechanism and this measurement does not directly apply.
Where it lands#
waitress for WSGI. It is the reason waitress exists: pure Python, no dependencies, no fork, and it has been the Windows answer for years. Its release gap (21 months, S1) and its shutdown behavior are the costs.
uvicorn for ASGI, knowing it is the degraded configuration and that the gap widens with load.
Not gunicorn, and not uWSGI — one does not run, the other needs a C toolchain on a platform where that is a larger ask than on Linux.
This is the situation where the survey’s measurements help least and its structural findings help most, and a Windows deployment should treat every throughput number here as directional rather than applicable.
Situation 2 — A WSGI application on a VM#
Who#
A Flask, Django or Pyramid application on a long-lived host — a VM, a bare-metal box, a traditional deployment behind nginx. Nothing supervises the process except systemd, and nobody is adding replicas.
What eliminates options#
Something has to restart a dead worker, and on this host nothing else will. That makes process management a requirement rather than a duplicated layer, which is the exact inverse of situation 1.
WSGI also means the concurrency ceiling is the worker count, so the deployment has a number to get right and a default that is wrong.
What the measurements say#
The worker count is worth 7×, and the default is 1. L4: gunicorn sync goes from 967
requests a second at one worker to 6,765 at eight, with p50 latency falling from 62 ms to
7.9 ms. A service left on the default is running at an eighth of what the machine would give.
This is the largest actionable number in the survey for this persona, and it is a configuration flag.
gthread starts higher and flattens sooner — 1,512 rps at one worker against sync’s
967, but only 3.7× by eight workers against sync’s 7.0×. For handlers that block on I/O,
threads add concurrency inside each worker; for CPU-bound handlers they add contention.
gunicorn sync opens a TCP connection per request. L3 measured conns_per_request at
1.0 — no keep-alive. Behind nginx this matters less, because the proxy holds the client
connection; direct, it is pure overhead on every request.
Where it lands#
gunicorn, workers set to at least the core count, and gthread if the handlers wait.
Seventeen years of production, the most complete process model measured, and the one server
here that supervises properly.
waitress if the deployment must be dependency-free or is on Windows — situation 6. It is slower (2,600 rps against gunicorn’s 6,800 at eight workers) and it drops in-flight requests on SIGTERM, which matters on every restart.
uWSGI only where it is already running. S1: sdist-only packaging, so every install needs a compiler, and there is no ASGI future in it.
S4: Strategic
Which of these will still be maintained in five years#
Every other comparison in this survey assumes the server still exists. These are the signals that bear on whether it will.
What was measured#
GitHub REST API, 2026-08-28. Rung cited — this is the platform’s own accounting, read
carefully, not something this survey ran.
- Contributor concentration — the share of commits by the single most active contributor, and by the top three. A bus-factor proxy.
- Issue close ratio — closed against closed plus open, using the search API with
type:issueso pull requests are excluded. - Release cadence — from S1’s PyPI data.
How to read a bus factor here#
A high top-one share is a risk signal, not a verdict, and it reads differently depending on what stands behind the individual:
- Behind a foundation with a governance process — daphne under
django/, waitress under Pylons — a concentrated commit history is division of labor. - Behind one person and no institution, it is the whole risk.
- Behind one person maintaining several load-bearing projects, it is the same risk compounded, which is uvicorn’s situation and the reason this pass exists.
The number is identical in all three cases. What follows says which case each project is in.
The close ratio is the more useful number#
Release cadence measures whether anything is shipping. The close ratio measures whether anyone is looking — and the two come apart in both directions here.
waitress has not released in 21 months and closes 91% of its issues. hypercorn released nine months ago and closes 54%. Cadence alone would rank those the wrong way round.
hypercorn — viability#
Position: the clearest decline in this survey, and three independent signals agree.
| signal | value | reading |
|---|---|---|
| close ratio | 54.1% (124 open, 146 closed) | worst of any maintained server here |
| top-1 commit share | 84.2% | one author |
| top-3 | 88.3% | |
| last release | 2025-11-08 | nine months |
| throughput | saturates at concurrency 8 (L3) | measured, not inferred |
| installs | 7.9 million a month | overtaken by granian’s 16.7 |
Three signals, and they are not the same signal#
Any one of these could be read benignly. Together they are consistent only with a project whose maintainer has more issues than time.
Nearly half the issues are open — 124 against 146 closed. Every other maintained server here is above 90%, and hypercorn is at 54.1%. This is the number that distinguishes a slow release cadence from a stalled project, and it is why waitress’s 21-month gap reads differently from hypercorn’s nine-month one.
84.2% of commits are one person’s, with no institution behind the project.
It stopped scaling in the measurement, at concurrency 8, which is not a viability signal on its own but means the technical case for choosing it anyway is weaker than its feature list suggests.
What it still has#
HTTP/2 and HTTP/3 from a pure-Python install, and trio support — the only server here offering either. Both are real capabilities and neither is available elsewhere in this category.
That is the difficulty: hypercorn is the only answer to two questions, and it is the weakest project in the survey. A reader who needs HTTP/3 at the application server or an application written against trio has one option, and this is the state of it.
What would change this#
A release, and a visible reduction in the open-issue count. Nine months is not long; 54% is the number to watch, and it is the one that has been moving in the wrong direction.
S4 verdict#
The concentration under FastAPI is the survey’s finding#
S1 established that encode/uvicorn is now Kludex/uvicorn, a transfer rather than a fork,
and 1.241 established the same for Starlette. One person maintains both.
Each is installed 676 million times a month. They are the two layers beneath FastAPI, and FastAPI itself — 1.241 measured — has 51.8% of its commits from a single contributor with no foundation behind it.
So the most popular Python API stack is three layers deep and every layer is individually maintained, none under an institution, and two of the three by the same person. No per-project metric shows this: uvicorn’s own bus factor is 35.1%, the second-healthiest here.
This is not a prediction of failure. All three are actively maintained with good close ratios. It is a statement that the usual mitigation — “we could switch” — is weaker than it sounds, because switching servers is easy and switching the toolkit underneath the framework is not.
The close ratio ranks these better than the release date#
waitress: 21 months without a release, 91.4% close ratio, 22 open. The tracker is tended and there is nothing being added — a coherent state for a dependency-free implementation of a specification frozen in 2003.
hypercorn: released nine months ago, 54.1% close ratio, 124 open against 146 closed.
Cadence alone ranks those the wrong way round. hypercorn is the project in trouble and waitress is the one that is finished.
Strategic verdicts#
| server | five-year outlook | what would cost you |
|---|---|---|
| gunicorn | safest here — 97.3% close, 26.x, 17 years | no ASGI of its own; no Windows |
| uvicorn | healthy, concentrated upward — 97.5% close, 35.1% top-1 | shares a maintainer with Starlette; 0.x |
| daphne | institutionally safest — under django/, 91.6% close | slowest ASGI measured; drops on SIGTERM |
| waitress | finished, not failing — 91.4% close, 21-month gap | one thread pool; drops on SIGTERM |
| granian | healthy, one author — 90.9% close, 81.8% top-1, 2.x | platform wheel; RSGI has one implementation |
| uvloop / httptools | load-bearing and quiet — 69.5% / 72.6% close | 2.8× of uvicorn’s throughput depends on them |
| hypercorn | declining — 54.1% close, 84.2% top-1, saturates at c=8 | the only source of HTTP/3 and trio |
| uWSGI | maintenance at best — 794 open, 55.9% close | sdist-only; no ASGI path |
| meinheld / bjoern | dead — 2020 and 2022 | still recommended in old benchmark posts |
The accelerators deserve a line of their own#
uvloop and httptools carry 2.8× of uvicorn’s throughput under load (L5), and neither is named in most deployment documentation. uvloop’s close ratio is 69.5% with 127 open issues, and it last released in October 2025.
They are optional dependencies that most of the ecosystem’s performance rests on, maintained separately from the server that depends on them, by a different organization again. That is a third concentration nobody counts.
The one place the field has no answer#
HTTP/3, or trio, at the application server. hypercorn is the only option and it is the weakest project measured. A reader with either requirement has one choice and should know its state before making it.
uvicorn — viability#
Position: healthy on its own numbers, and carrying a concentration that is not visible in them.
| signal | value |
|---|---|
| top-1 commit share | 35.1% |
| top-3 | 72.6% |
| close ratio | 97.5% (18 open, 702 closed) |
| last release | 2026-08-19 |
| version | 0.52.4, still 0.x after nine years |
| installs | 676 million a month |
The project itself is in good order#
35.1% top-1 is the second-lowest concentration in this survey, and a 97.5% close ratio with eighteen issues open is a tracker under control. It shipped nine days before this measurement. On every signal a repository can produce about itself, uvicorn is fine.
The risk is not in this table#
encode/uvicorn resolves to Kludex/uvicorn — a transfer, established in S1 the same
way 1.241 established Starlette’s. Marcelo Trylesinski maintains both.
That is the finding, and no per-repository metric shows it. uvicorn’s bus factor looks moderate; Starlette’s looked moderate in 1.241 (31.5%); and the two projects share their principal maintainer. The layer that runs the request and the layer that dispatches it beneath the most popular Python API stack have one person in common, and each is installed 676 million times a month.
Neither package’s metadata mentions the transfer. Documentation and links predating it point
at encode.
What this does and does not mean#
It is not a prediction of abandonment. Both projects are actively maintained right now and their close ratios are among the best measured.
It means the risk should be named rather than inferred from a star count, and that the usual mitigation — “the alternative is fine, we could switch” — is weaker than it looks: switching servers is easy and switching the ASGI toolkit underneath FastAPI is not.
The version number#
0.52.4, after nine years and no 1.0. Combined with the concentration, uvicorn carries the same pair of risks 1.241 found in FastAPI — no institution and no stability commitment — which matters because FastAPI, Starlette and uvicorn are the three layers of one stack and all three carry it.
uWSGI — viability#
Position: maintenance at best, and the backlog says less than that.
| signal | value |
|---|---|
| open issues | 794 |
| closed | 1,008 |
| close ratio | 55.9% |
| top-1 commit share | 70.2% |
| last release | 2025-10-11 |
| version | 2.0.31 — the 2.0 line since 2013 |
| installs | 2.0 million a month, the lowest here |
| packaging | sdist only |
794 open issues#
The largest backlog in the survey by an order of magnitude, and a 55.9% close ratio. For comparison, gunicorn — a server of similar age and far higher usage — has 61 open and closes 97.3%.
That gap does not come from activity — both projects still release. It comes from whether anyone is working through what has accumulated, and on uWSGI the answer is visibly no.
The install base tells the same story from the other side#
3,544 stars against 2.0 million installs a month. More stars than granian, hypercorn, waitress or daphne — a record of how widely uWSGI was used — and fewer installs than any of them except the dead ones.
A large audience arrived and a small one stayed.
The packaging problem is now the deciding one#
Sdist-only means every install compiles C. In 2026, when most deployments are slim container images without a toolchain, that is a failure mode rather than an inconvenience, and the workaround is a multi-stage build for a server whose alternatives install from a wheel.
What it still has#
Genuine capability that nothing else here matches: the uwsgi binary protocol and deep
nginx integration, a caching framework, cron, queues, and two decades of production use in
large deployments.
Where it is already running and working, none of this is an argument to move. For new work it is hard to justify: no ASGI path, a compiler requirement, and 794 open issues.
Viability signals#
GitHub REST API, 2026-08-28. Issue counts use type:issue, so pull requests are excluded.
| server | contributors | top-1 | top-3 | open | closed | close ratio | last release |
|---|---|---|---|---|---|---|---|
| uvicorn | 100+ | 35.1% | 72.6% | 18 | 702 | 97.5% | 2026-08-19 |
| gunicorn | 100+ | 59.8% | 76.2% | 61 | 2,160 | 97.3% | 2026-08-24 |
| daphne | 70 | 56.7% | 73.3% | 29 | 316 | 91.6% | 2026-07-21 |
| waitress | 54 | 42.3% | 72.8% | 22 | 235 | 91.4% | 2024-11-16 |
| granian | 48 | 81.8% | 92.7% | 33 | 329 | 90.9% | 2026-08-23 |
| httptools | 23 | 33.3% | 63.8% | 20 | 53 | 72.6% | 2026-05-25 |
| uvloop | 72 | 35.8% | 52.9% | 127 | 290 | 69.5% | 2025-10-16 |
| uWSGI | 100+ | 70.2% | 86.5% | 794 | 1,008 | 55.9% | 2025-10-11 |
| hypercorn | 65 | 84.2% | 88.3% | 124 | 146 | 54.1% | 2025-11-08 |
“100+” is the API’s first page; the tail was not paged and is not needed for a concentration reading.
The two numbers that do not agree#
waitress: 21 months without a release, and a 91.4% close ratio with 22 issues open. The tracker is tended; there is simply nothing being added. For a dependency-free implementation of a specification frozen in 2003, that is a coherent state and not a warning.
hypercorn: released nine months ago, and a 54.1% close ratio with 124 open against 146 closed. Nearly half its issues are open. Cadence says maintained; the backlog says the maintainer is not keeping up with it.
Ranking these on release date alone puts them the wrong way round.