Gunicorn + Uvicorn Workers and Graceful Shutdown
Concept
WSGI and ASGI are two different contracts between a Python web application and the server that runs it. WSGI (PEP 3333) models a request as one synchronous call: server invokes application(environ, start_response), gets a response, moves on — no native concept of long-lived connections, WebSockets, or awaiting I/O mid-request. ASGI generalizes this to an async, event-based interface so a single connection can span multiple messages over time, which is what makes WebSockets, Server-Sent Events, and async def request handlers possible.
Gunicorn is a mature, pre-fork process manager: a master process forks N worker processes, restarts ones that die, and handles rolling worker restarts — but its built-in sync worker only speaks WSGI. Uvicorn is an ASGI server with excellent single-process performance but a thinner process-management story on its own. The common production pattern combines them: gunicorn app:app -k uvicorn.workers.UvicornWorker -w 4 — Gunicorn's master process supervises N Uvicorn-worker child processes, each running the ASGI app. This gets Gunicorn's operational maturity (worker recycling, --max-requests to guard against memory leaks, signal handling) together with Uvicorn's ASGI protocol implementation. Newer stacks increasingly run Uvicorn directly with its own multi-worker support (--workers) rather than pairing it with Gunicorn, since Uvicorn's own process manager has matured since the pairing pattern became conventional.
Tradeoffs
| Approach | Benefit | Cost |
|---|---|---|
| Gunicorn (sync WSGI workers) | Simplest model, one request per worker at a time — no await discipline required anywhere in the app |
Each worker blocks entirely on I/O; concurrency scales only with worker/process count, not cheap for I/O-heavy workloads (many slow upstream calls) |
Gunicorn + UvicornWorker (ASGI) |
Async I/O within each worker (many concurrent requests per process during I/O waits) plus Gunicorn's process supervision and graceful restarts | Two moving parts to understand when something goes wrong (which layer owns a given timeout/signal?); the whole app and its dependencies must be async-safe |
| Uvicorn standalone, multi-worker | One fewer layer, actively maintained multi-process mode | Less battle-tested process-management surface than Gunicorn's (which has run in production across the ecosystem for over a decade) |
Worker count = 2 × CPU + 1 (classic sync formula) |
Well-established rule of thumb for CPU/blocking-bound sync workloads | Directly wrong for async ASGI workers — an async worker already services many concurrent requests per process, so applying the sync formula wildly over-provisions processes and memory |
When to use / when not to
- Use Gunicorn +
UvicornWorkerfor FastAPI/Starlette-style ASGI apps where you also want Gunicorn's--max-requests/--max-requests-jitter(recycle a worker after N requests to bound memory growth from leaks) and its signal-based graceful restart (HUPto reload workers without dropping the listening socket). - Use plain sync Gunicorn workers for classic WSGI apps (Flask, Django without ASGI) with no async code paths — adding an ASGI layer buys nothing if nothing in the app actually awaits.
- Size async workers starting from
CPU cores + 1and tune upward only after profiling shows headroom — the sync2×cores+1formula assumes workers spend most of their time blocked, which isn't true for an event-loop-driven worker handling concurrent I/O. - Don't skip
-k uvicorn.workers.UvicornWorker(or the ASGI-aware equivalent) when running an ASGI app under Gunicorn — with no worker class specified, Gunicorn defaults to its sync WSGI worker, which cannot execute an ASGI callable correctly and fails or silently serves requests wrong depending on the app framework's compatibility shim.
Common pitfall — graceful shutdown under orchestration
A container orchestrator (ECS, Kubernetes) sends SIGTERM to signal "stop accepting new work, then exit" and, after a grace period, SIGKILL if the process hasn't exited. Gunicorn's graceful_timeout setting controls how long it waits for in-flight requests to finish after SIGTERM before force-killing workers — and this value must be propagated correctly to the ASGI layer underneath: Gunicorn passes its graceful_timeout down to Uvicorn's timeout_graceful_shutdown when running as a Gunicorn worker. If the orchestrator's own grace period (Kubernetes terminationGracePeriodSeconds, ECS's stop timeout) is shorter than Gunicorn's graceful_timeout, the orchestrator sends SIGKILL before Gunicorn finishes draining — in-flight requests get hard-killed mid-response, which looks like a random 5xx spike correlated with every deploy rather than an obviously-named "we killed it too early" bug. The fix is ordering the three timeouts correctly: graceful_timeout (Gunicorn) ≤ timeout_graceful_shutdown (Uvicorn, usually inherited) < the orchestrator's own grace period, with enough margin for slow requests to actually finish.
Engineering Lens
The WSGI/ASGI distinction is really a question about what shape of concurrency the application code is written for, and the deployment layer has to match that shape or the mismatch shows up as either wasted resources (sync-formula worker counts under an async server) or silent breakage (a WSGI-only server driving an ASGI app). The graceful-shutdown chain is the more general lesson: any time a signal or timeout gets passed through multiple supervising layers (orchestrator → process manager → application server → app), each layer's timeout has to be ordered correctly relative to the ones above and below it, or the outermost layer will act on stale information and kill something the innermost layer was still gracefully finishing.
Sources
- ASGI Worker — Gunicorn documentation
- Understanding Python Web Servers — WSGI, ASGI, Gunicorn, and Uvicorn Explained
- Uvicorn documentation