What an ASGI server does and where its cost sits

An ASGI server sits between the network and your application. It accepts TCP connections, terminates or parses HTTP (and upgrades to WebSocket where needed), and converts each request into an ASGI scope plus receive/send callables. It runs the lifespan startup and shutdown events, where apps open connection pools, and it calls your app for every request. It also handles keep-alive, backpressure, timeouts and worker processes.

Load balancer
     |
+----v--------------------------+
| ASGI server   (server owns)   |
|  accept, HTTP parse, lifespan |
|  workers, event loop          |
+----+--------------------------+
     | app(scope, receive, send)
+----v--------------------------+
| App (you own): routing,       |
| validation, logic, JSON       |
+----+--------------------------+
     |
  Database

The server's per-request work is small and fixed. Your work is usually not.

Illustrative estimate (assumed numbers, not a benchmark). Latency = server + app logic + DB + serialization. Assume 0.1 ms + 2 ms + 8 ms + 1 ms = 11.1 ms. The server accounts for 0.1 / 11.1 ≈ 0.9%. A server that is twice as fast saves 0.05 ms per request.

Blocking calls set a harder limit. A synchronous 50 ms call inside an async def handler stalls the whole event loop. Each loop can then serve at most 1 / 0.05 = 20 requests/s, and no server can change that.

The server choice matters when your handlers are cheap and fully async, when you need specific protocols, or when the process model must fit your container setup. The later sections cover each of these.

           Clients
              |
     +--------v---------+
     |  Load balancer   |
     +--------+---------+
              | TCP / HTTP
+-------------v-----------------------------+  <- ASGI server owns
| ASGI server process(es)                 |
|  accept conn -> parse HTTP / WS upgrade |
|  lifespan startup/shutdown              |
|  keep-alive, timeouts, backpressure     |
|  worker processes, event loop           |
+-------------+---------------------------+
              | app(scope, receive, send)
+-------------v---------------------------+  <- you own
| ASGI app (your code)                    |
|  routing -> validation -> logic         |
|  JSON serialization                     |
|  sync call in async def = loop stall    |
+-------------+---------------------------+
              | async driver / pool
+-------------v---------------------------+  <- usually most latency
| Database (query time, network RTT)      |
+-----------------------------------------+

Request path from load balancer through the ASGI server and application to the database: the server owns connection handling, HTTP parsing, lifespan and workers, while routing, logic, serialization and database calls belong to the app and usually dominate latency.

How Uvicorn, Granian and Hypercorn are built

The three servers differ mainly in which language parses HTTP and manages sockets. That choice drives their speed, how easy they are to debug, and what you install.

Uvicorn Granian Hypercorn
Core language Python Rust, calling into Python Python
Event loop asyncio, optional uvloop asyncio on the Python side, uvloop optional asyncio (uvloop optional) or Trio
Interfaces ASGI ASGI, WSGI, plus its own RSGI ASGI (check docs for WSGI)
Dependency footprint Small core; standard extras add compiled uvloop and httptools One compiled wheel Pure-Python stack: h11, h2, wsproto
Community knowledge Largest; the common default in FastAPI material Smaller and newer Smaller; closely tied to Quart

Pure Python vs. Rust. Uvicorn and Hypercorn parse requests in Python. You can step through the parser with a Python debugger, and their tracebacks are readable. Granian moves parsing, connection handling and I/O into Rust. That lowers the cost of each request, but a fault below the Python boundary is harder to inspect. The saving only matters when your handlers are cheap enough for server overhead to be a large share of each request.

Wheels. Granian and Uvicorn's speed extras depend on prebuilt binaries. On an uncommon platform, such as musl-based images or certain ARM targets, a missing wheel can force a source build. Check this before you commit to either.

Ecosystem. When something goes wrong in production, existing answers for Uvicorn are easiest to find. Hypercorn and Granian have fewer of them.

Feature sets change between releases. Confirm current protocol, loop and interface support in each project's own documentation.

Protocol support: when HTTP/2 and HTTP/3 actually matter

All three servers handle HTTP/1.1 and WebSockets, which covers most JSON APIs. They differ above that baseline. Uvicorn does not implement HTTP/2. Granian supports HTTP/2. Hypercorn supports HTTP/2 and can serve HTTP/3 over QUIC when its optional QUIC dependency is installed.

These differences rarely matter in a typical deployment. When a load balancer or reverse proxy terminates TLS, the client negotiates HTTP/2 or HTTP/3 with the proxy, not with your process. The proxy then usually opens plain HTTP/1.1 connections to the upstream server. Your server never sees the client's protocol, so Hypercorn's HTTP/3 support has no effect there.

Server-side HTTP/2 matters when it has to survive all the way to the application:

  • Long-lived bidirectional streams. gRPC-style streaming multiplexes many concurrent streams over one connection, and translating to HTTP/1.1 breaks that model.
  • Service meshes that keep HTTP/2 end to end. A sidecar may forward HTTP/2 to the application, often as cleartext h2c. If the server only speaks HTTP/1.1, the sidecar has to downgrade, and you lose multiplexing on that hop.
  • No proxy at all. In this case the server terminates client connections directly.
Deployment topology Protocols the server needs
TLS-terminating proxy or load balancer → server HTTP/1.1, WebSockets
Mesh sidecar forwarding HTTP/2 HTTP/1.1, HTTP/2 (often h2c)
Streaming RPC through a pass-through proxy HTTP/2
Server exposed directly to clients HTTP/1.1, HTTP/2, optionally HTTP/3

Process and worker models in containers

Three layouts cover most deployments.

One process per container. Run a single worker, for example uvicorn app:app --host 0.0.0.0 --port 8000, and let the orchestrator add replicas. Each pod is one event loop on roughly one core, so the CPU limit, the readiness probe and the restart policy all describe the same unit. This is usually the easiest layout to reason about.

Server-managed workers. All three servers can fork worker processes themselves, for example uvicorn app:app --workers 4, granian --interface asgi app:app --workers 4 or hypercorn app:app --workers 4. Uvicorn and Hypercorn run one Python event loop per process. Granian's workers also run Rust-side runtime threads for I/O, so each worker can use more than one core.

Gunicorn with Uvicorn workers. Run gunicorn app:app -k uvicorn.workers.UvicornWorker -w 4. Gunicorn adds a mature supervisor that handles graceful reloads and timeouts, but it is another process layer to configure. Flag names and worker class paths change between releases, so check them against the current docs.

The common failure is oversubscription. Worker counts are often derived from the host's core count, but the container's CPU limit is set by a cgroup quota. Four workers under a one-CPU limit compete for that quota. The kernel throttles them, which shows up as tail-latency spikes rather than errors.

Multi-worker pods also blur autoscaling signals. Assume, as an illustrative estimate, four workers with a two-CPU limit. Two workers are saturated and two are idle, so pod CPU reads about 50%. Requests queued on the busy loops are slow, but the CPU-based autoscaler sees no reason to scale out.

#!/usr/bin/env bash
# entrypoint.sh: size the worker count from the container's CPU quota, not the host's cores.
# Flag names are current as of writing; check them against your server's docs.
set -euo pipefail

# nproc usually reports host (or cpuset) cores. It ignores the cgroup CPU quota.
HOST_CORES=$(nproc)

# cgroup v2 exposes the quota as "<quota> <period>" or "max <period>" (no limit).
WORKERS="$HOST_CORES"
if [ -r /sys/fs/cgroup/cpu.max ]; then
  read -r QUOTA PERIOD < /sys/fs/cgroup/cpu.max
  if [ "$QUOTA" != "max" ]; then
    # Round up, e.g. 150000/100000 (1.5 CPUs) -> 2 workers.
    WORKERS=$(( (QUOTA + PERIOD - 1) / PERIOD ))
  fi
fi
[ "$WORKERS" -lt 1 ] && WORKERS=1

echo "host cores=$HOST_CORES, workers from cgroup quota=$WORKERS" >&2

if [ "${SINGLE_WORKER:-1}" = "1" ]; then
  # Layout 1: one event loop per pod; scale out with replicas.
  exec uvicorn app:app --host 0.0.0.0 --port 8000
else
  # Layout 2: server-managed workers, capped at the quota to avoid throttling.
  # Granian also runs runtime threads per worker, so it may need fewer workers.
  exec uvicorn app:app --host 0.0.0.0 --port 8000 --workers "$WORKERS"
fi

An entrypoint script that defaults to a single Uvicorn worker and, when multiple workers are requested, sizes them from the cgroup v2 CPU quota instead of the host core count to avoid oversubscription.

Running a meaningful ASGI server benchmark

The test app has two routes. /ping returns a constant and measures pure server overhead. /orders/{id} passes through an auth middleware that validates a bearer token, runs one query against a real database, and serializes a Pydantic model. The second route is the one that predicts production behaviour.

Method

  1. Run each server in a container with the same CPU limit, and pin the container to fixed cores where the host allows it. Without a limit, one server can quietly use more cores than the others.
  2. Match the worker count, log level and access-log setting across servers. Access logging alone can account for a visible share of hello-world latency.
  3. Run the load generator on a separate machine. If it shares the host, it competes with the server for CPU.
  4. Warm up for a minute and discard those results. This lets connection pools fill and caches settle.
  5. Drive a fixed request rate rather than a closed loop, and step the rate upward. At each step, record p50, p99 and error rate. A server can win on throughput while timing out 2% of requests.

Illustrative estimate (assumed inputs)

Assume server A adds 1.0 ms of overhead per request and server B adds 0.5 ms. On the realistic route, assume a 20 ms database call plus 2 ms of auth and serialization.

Route Server A Server B B faster by
/ping 1.0 ms 0.5 ms 50%
/orders/{id} 23.0 ms 22.5 ms ~2%

Published hello-world comparisons measure only the top row. They often also mix worker counts, logging settings and unpinned CPUs. That combination magnifies a sub-millisecond gap into a headline multiplier that your real routes will never show.

# bench_app.py: serve this same module with every server under test (app path: bench_app:app)
import os
from contextlib import asynccontextmanager
import asyncpg
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel

DSN = os.environ.get("DATABASE_URL", "postgresql://bench:bench@db-host:5432/bench")
TOKEN = os.environ.get("BENCH_TOKEN", "placeholder-token-not-a-secret")

class Order(BaseModel):
    id: int
    customer_id: int
    total_cents: int
    status: str

@asynccontextmanager
async def lifespan(app: FastAPI):
    # One fixed-size pool per worker, so every server gets the same DB capacity per worker.
    app.state.pool = await asyncpg.create_pool(DSN, min_size=5, max_size=5)
    yield
    await app.state.pool.close()

app = FastAPI(lifespan=lifespan)

@app.middleware("http")
async def bearer_auth(request: Request, call_next):
    # /ping skips auth so it isolates pure server overhead; every other route pays for auth.
    if request.url.path != "/ping" and request.headers.get("authorization") != f"Bearer {TOKEN}":
        return JSONResponse({"detail": "unauthorized"}, status_code=401)
    return await call_next(request)

@app.get("/ping")
async def ping():
    return {"ok": True}  # hello-world baseline: no I/O, trivial serialization

@app.get("/orders/{order_id}", response_model=Order)
async def get_order(order_id: int, request: Request):
    # Realistic path: one real DB round trip plus Pydantic serialization.
    row = await request.app.state.pool.fetchrow("SELECT id, customer_id, total_cents, status FROM orders WHERE id = $1", order_id)
    if row is None:
        raise HTTPException(status_code=404)
    return Order(**dict(row))

A minimal FastAPI benchmark app with a /ping baseline and an /orders/{id} route that runs bearer-token middleware, one asyncpg query and Pydantic serialization, so the same module can be served unchanged by Uvicorn, Granian or Hypercorn.

FastAPI deployment details that change between servers

Production behaviour lives in a handful of settings that each server names and defaults differently.

Lifespan. Put connection pools, HTTP clients and cache warm-up in FastAPI's lifespan handler, not in module import code. All three servers run lifespan once per worker, so a pool sized for one process gets multiplied by the worker count. Size the pool per worker. If startup fails, the worker should exit rather than serve traffic half-initialised.

SIGTERM and draining. During a rolling deploy, the orchestrator removes the pod from endpoints and sends SIGTERM at roughly the same moment, so requests can keep arriving after the signal. The graceful-shutdown timeout should be longer than your slowest normal request and shorter than the pod's termination grace period. A short pre-stop delay covers the endpoint propagation gap. Long-lived WebSockets need explicit close handling, because they never drain on their own.

Keep-alive. Set the server's idle keep-alive timeout longer than the load balancer's idle timeout. If the server closes first, the balancer can reuse a dead socket and return intermittent 502s.

Proxy headers. Trust X-Forwarded-For only from the balancer's address range. Trusting every source lets clients spoof their IP into logs and rate limiters.

Access logs. Per-request logging formatted in Python costs measurable CPU at high request rates. Emit structured logs from middleware or the proxy instead.

Migration checklist

  • Map each timeout, including keep-alive, graceful shutdown and lifespan, to its new name and default.
  • Re-verify forwarded-IP trust.
  • Diff log fields against your parsers and dashboards.
  • Test WebSocket close codes and ping behaviour.
  • Run a rolling deploy under load in staging.

Switching servers can quietly change shutdown timing, header normalisation, WebSocket edge cases and log format. A server change is a staging load-test exercise, not a config swap.

# app.py: per-worker resources live in lifespan, not at import time.
from contextlib import asynccontextmanager

import httpx
from fastapi import FastAPI


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Runs once per worker. With 4 workers this means 4 pools, so size per worker.
    client = httpx.AsyncClient(
        base_url="",
        timeout=5.0,
        limits=httpx.Limits(max_connections=20),
    )
    # If this raises, startup fails and the worker exits instead of
    # serving traffic half-initialised.
    await client.get("/healthz")
    app.state.http = client
    yield
    # Runs on graceful shutdown, after in-flight requests have drained.
    await client.aclose()


app = FastAPI(lifespan=lifespan)


@app.get("/items/{item_id}")
async def get_item(item_id: int):
    r = await app.state.http.get(f"/items/{item_id}")
    return r.json()


# Launch (Uvicorn). Assumes LB idle timeout 60s and pod grace period 45s:
#   uvicorn app:app --host 0.0.0.0 --port 8000 --workers 4 \
#     --timeout-keep-alive 75 \          # longer than the LB's 60s idle timeout
#     --timeout-graceful-shutdown 30 \   # > slowest request, < 45s grace period
#     --proxy-headers --forwarded-allow-ips 10.0.0.0/8 \  # trust only the LB range
#     --no-access-log                    # log from middleware or the proxy instead

A FastAPI lifespan handler that opens a per-worker HTTP client pool, fails fast on startup and closes cleanly on shutdown, with a Uvicorn launch command whose keep-alive, graceful-shutdown, forwarded-IP and access-log settings follow the rules above.

Operational fit, engineering cost and a decision guide

Day-two costs usually outweigh raw throughput. Uvicorn logs through Python's standard logging module, so your existing formatters, JSON handlers and log shippers apply unchanged. Granian's access logging lives partly in its Rust core, which leaves fewer Python hooks for controlling format. With either server, put request metrics and tracing in ASGI middleware or OpenTelemetry instrumentation rather than in server-specific features. That way the observability layer survives a server swap.

Granian's configuration surface is larger: threading modes, runtime threads and backpressure settings, all of which need tuning and documentation. Its failure mode also differs. A Python exception produces a readable traceback. A segfault or panic in a native extension produces a dead worker and a core dump, and debugging that requires different skills. Dev reload is a convenience on every server and should never reach production manifests.

Uvicorn's documentation and its very wide adoption keep onboarding and troubleshooting cheap. Before you switch servers, profile the application. Serialization, ORM queries and synchronous calls blocking the event loop usually dominate request latency, and the server's share is typically small.

Server Use when Avoid when Alternative
Uvicorn The default for FastAPI and most APIs You need Trio or HTTP/3 Hypercorn
Granian Profiling shows server overhead matters for high rates of cheap requests, or you want one server for both ASGI and WSGI The team can't debug native crashes, or your logging pipeline depends on Python handlers Uvicorn
Hypercorn You need Trio, HTTP/2 or HTTP/3 terminated in-process A proxy already terminates those protocols Uvicorn behind the proxy

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to uvicorn vs granian, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.