The map nobody gives you for sync, async, threads, and connections in Django
Why your RDS exploded, why gunicorn + uvicorn isn't redundancy, and what each timeout actually does when you ship Django to production.
You flip Django from WSGI to ASGI expecting a performance win. You wire up gunicorn with the uvicorn worker, follow the tutorials, ship to production. A week in, RDS connections explode, requests start returning 502s with no clear pattern, and nobody can say why. The good news: there is a clean explanation for every one of those symptoms, and it ties together five concepts that usually get taught separately — which is why it feels like you need a PhD to run Django well. You don't.
This is the map I wish I'd read before shipping async Django to production. It covers sync vs async, gunicorn vs uvicorn, the thread story, database connections, and the timeouts — not as isolated topics, but as one chain where each piece sets up the next. No jargon for its own sake. Real numbers. With the mistakes I made myself.
If you know Django but stumble when someone says "ASGI", "worker class", or "thread_sensitive" — this is for you.
What WSGI and ASGI are, with no mystery
WSGI and ASGI are protocols. They define how the server (gunicorn, uvicorn) talks to your application (Django). It's a contract — nothing more.
WSGI: each waiter (worker) handles one table at a time. Takes the order, walks it to the kitchen, stands there until the plate is ready, walks it to the table. Only then picks up the next order. If a table drags (the diner can't decide on the wine, the kitchen is slammed), the waiter just waits. Want to serve more diners at once? Hire more waiters.
ASGI: the waiter takes table 1's order, drops it in the kitchen, and while that food cooks, takes table 2's order, drops table 3's plates, opens table 4's wine. When table 1's food comes up, they grab it. One waiter, dozens of tables — as long as their time is spent waiting, not doing.
Mapped to a backend: when your code awaits on something I/O-bound (a database query, an API call, a file read), the async worker frees the "waiter" to handle another request while it waits. A sync worker just stands there.
But ASGI isn't universally faster
This is the misconception that comes up most often in technical conversations. ASGI has overhead. The event loop has to coordinate tasks, schedule callbacks, manage contexts. For a single fast request, that shows up:
vs WSGI per simple request
concurrent I/O-bound workloads
async worker handles vs sync
On a DRF CRUD endpoint that finishes in 30ms, those 15ms are a 50% regression. On an app that fans out to 3 external APIs in parallel, ASGI wins 3× because it can issue the calls concurrently. Same engine, opposite result.
"ASGI isn't faster — it's more concurrent. Those are different things."
When each one makes sense
I/O-bound with concurrency
SSE, WebSocket, streaming, fan-out to multiple external APIs, long polling, anything that spends most of its time waiting on something that isn't CPU.
Fast CRUD, DB-bound
Request comes in, query the database, return JSON, done. All under 50ms. Adding async here is overhead with nothing to show for it.
Pure CPU-bound
Image processing, ML inference, heavy parsing. The event loop won't speed up CPU that's already pinned — it can actively get in the way.
Long-lived connections
Streaming, voice UX, real-time. WSGI keeps a worker pinned for the entire connection. ASGI is effectively required.
Gunicorn and Uvicorn: who does what
gunicorn --worker-class uvicorn.workers.UvicornWorker, gunicorn doesn't touch HTTP at all — it just supervises the uvicorn workers that do the real work. Not redundancy: gunicorn brings rolling restarts, app preload, and signal handling that standalone uvicorn still doesn't match for production maturity.
The common confusion: "if uvicorn is already an ASGI server, why run it inside gunicorn? Isn't that running twice?" It isn't. They do different jobs.
Gunicorn: the supervisor
Gunicorn is a process manager. When you use UvicornWorker, this is what it does — and only this:
- Spawns worker processes (forks at startup)
- Watches them via heartbeat
- Restarts any worker that crashes
- Handles signals (SIGTERM, SIGUSR1, SIGHUP) during deploys and reloads
- Coordinates graceful shutdown
- Implements rolling restart by request count (defense against memory leaks you haven't found yet)
In this mode, gunicorn does not parse HTTP. The socket is opened by the workers. Each worker is a full uvicorn instance.
Uvicorn: the actual ASGI server
Uvicorn is what does the HTTP work itself: request parsing with httptools, the event loop on uvloop, and dispatch to your ASGI app (Django). Under gunicorn + UvicornWorker, every worker is a uvicorn process.
Standalone uvicorn can spawn workers too
Today you can run uvicorn --workers 2 app:application and get the same model: one master, two workers. It works. The real question is: what do you actually gain by keeping gunicorn out front?
| Capability | Gunicorn + UvicornWorker | Uvicorn standalone |
|---|---|---|
| HTTP performance | httptools/uvloop | httptools/uvloop (same) |
| Rolling restart by request countKills slow memory leaks before you ever diagnose them | ✓ --max-requests |
✗ Not built in |
| App preload (memory savings)Saves 80–150MB per worker via copy-on-write | ✓ --preload |
✗ Not built in |
| Lifecycle hookspost_fork, pre_request, worker_exit, etc. | ✓ Rich | Limited (ASGI lifespan) |
| Signal-handling maturity | 15+ years battle-tested | ~3 years in production |
| Ops complexity | One extra layer | Simpler |
For Django in production, the consensus pick is still gunicorn + UvicornWorker. For FastAPI in containers, the official recommendation has shifted to "run uvicorn directly, let the orchestrator (ECS/K8s) supervise". Both calls are right for their context.
Threads: where most people get lost
sync_to_async the ORM uses underneath. Understanding this part explains 80% of the strange performance bugs that show up after migrating.
A thread is the OS's unit of parallelism. For our purposes, three things matter: how many threads exist, who's running on each one, and what each one "remembers" (thread-locals).
To see what changes when you move from sync to async, here are three scenarios. Each one answers a single question: how many threads live inside one worker, and how many database connections does that mean?
Scenario 1 · Sync Django (the classic model)
Each worker is a Python process. Each process has a thread pool (gunicorn sync default: 1 thread per worker; with --worker-class gthread: whatever you configure). One thread serves one request, start to finish.
CONN_MAX_AGE does). Total is predictable: workers × threads.
Clean, predictable. Want more throughput? More workers or more threads. Each thread serves one request at a time. This is the model every Django book teaches.
Scenario 2 · Pure async Django (the pretty theory)
When you mark a view async def and use the async ORM (aget, afilter, acreate), it looks like you traded threads for coroutines. It looks that way.
But Django predates async/await — the ORM is still synchronous underneath. aget is just a thin wrapper around sync_to_async(get). Every async ORM call goes to a thread executor to run the sync code. The threads didn't disappear — they just got hidden.
The good surprise: by default Django uses thread_sensitive=True, which makes every async ORM query in the entire event loop run on the same thread. Not one thread per request. One thread, period.
Why? Because the sync ORM uses thread-locals (cursor, transactions) that aren't safe across threads. Pinning everything to one thread lets Django avoid race conditions without making you think about it.
Scenario 3 · Real async Django (with sync middleware)
Here's where 95% of people trip. The Django docs have a paragraph that's easy to miss:
Translation: if you have any sync middleware in MIDDLEWARE (Django auth, sessions, CSRF, DRF authentication — basically every real Django project), each concurrent request now takes its own thread from the pool. And those threads are pooled — reused across requests, each carrying its own thread-local connection.
That's the punchline. The clean story of "async doesn't burn threads" is a simplification that doesn't survive the first deploy of real Django + DRF. In practice, you still pay one thread (and one connection) per concurrent request.
Hold scenario 3 in your head. It's the key to the next chapter.
Database connections: the gotcha
CONN_MAX_AGE is not a connection pool. It's per-thread keep-alive. In sync Django that's safe. In async Django with sync middleware (the real scenario), threads × CONN_MAX_AGE blow up fast. Fix: a real connection pool (RDS Proxy, PgBouncer, or the native pool in Django 5.1+).
This is the chapter that costs the most to learn in production — because the symptom only shows up under real load, and until then everything looked fine.
What CONN_MAX_AGE actually does
Confession: for years I thought CONN_MAX_AGE = 60 meant "Django keeps a pool of connections up to 60 seconds old". Wrong. It means: each thread, individually, keeps ITS connection alive for 60s after a request ends, waiting to be reused on the next request served by the same thread.
It isn't a pool. It's thread-local keep-alive.
Why this blows up under async
Back to scenario 3 from chapter 3: a Django + DRF app (sync middleware in the chain) running async. Each request grabs a thread from the pool. Each thread holds its own connection. With CONN_MAX_AGE = 60:
That's exactly what happened to me. CONN_MAX_AGE = 60, which had been working perfectly under WSGI, became the source of the fire under ASGI.
"CONN_MAX_AGE isn't a pool. It's thread-local keep-alive. In async with sync middleware, it multiplies."
The natural reflex: CONN_MAX_AGE = 0
It's the first reaction. It works — it stops the bleeding immediately. But it's worth understanding what you're paying before you celebrate.
With CONN_MAX_AGE = 0, Django opens a connection on the first query of each request and closes it when the request ends. Multiple queries inside the same request reuse that connection — so the TCP+TLS+auth handshake (~3–6ms) is paid once per request, not per query. For an endpoint that responds in 50–100ms, that's 5–10% of overhead.
Not catastrophic. But it's overhead that disappears the moment you put a real pool in front of the database — and Django itself recommends doing exactly that under ASGI:
CONN_MAX_AGE = 0 combined with an external pool — not persistent connections without a pool, and not a pool without persistent connections.
The recommended path: an external connection pool
You need a layer that actually keeps connections warm to the database, independent of how many threads or requests your app has open. Three options:
| Solution | How it works | When to pick it |
|---|---|---|
| RDS ProxyAWS-native | Sits between your app and RDS, pools transparently. App connects to the proxy; proxy multiplexes onto a small set of real RDS connections. | You're on AWS. You want to fix this with a console toggle and an endpoint change. |
Django 5.1+ native poolpsycopg pool |
Pool inside Django via OPTIONS={"pool": True}. Per-worker, not cross-process. |
Self-hosted, or avoiding extra cost. Works well when you control the worker count. |
| PgBouncerthe classic | Separate process between app and Postgres. More flexible, lighter. | Kubernetes, your own infra, or a setup that needs more control. |
With an external pool out front, the "real" handshake between pool and database is paid once (at pool startup) and amortized across every request. Your app just opens and closes connections to the pool — which is light (no TLS, same VPC) and built for exactly this.
idle_client_timeout of 1800s. If you choose to keep CONN_MAX_AGE > 0 even with a proxy in front (a micro-optimization), make sure it stays below that limit. Otherwise Django will throw OperationalError: server closed connection unexpectedly when the proxy closes its side first.
The timeouts that chain
Five timeouts matter for your ECS + ALB + gunicorn + Django setup. Each lives at a different point in the lifecycle and protects against a different failure. Here are all five, and how they relate.
The five timeouts
| Timeout | Where it lives | What it controls |
|---|---|---|
ALB idle timeout |
AWS console | How long the ALB holds an idle TCP connection between client and target. Default 60s. |
--keep-alive |
Gunicorn | How long a worker keeps an idle connection open waiting for the next request. Default 5s. |
--timeout |
Gunicorn | How long a worker can stop responding to the master before being killed. Default 30s. |
--graceful-timeout |
Gunicorn | On SIGTERM, how long to wait for workers to finish in-flight requests before SIGKILL. Default 30s. |
stopTimeout |
ECS task definition | How long ECS waits for the container to stop before forcing SIGKILL. Default 30s. |
The order that matters
These timeouts have to grow as you move away from the code:
Why it matters: the phantom 502
The scenario: ALB idle timeout at 60s (default). Gunicorn keep-alive at 5s (default). A connection sits idle for 6 seconds, gunicorn closes it on its side, the ALB still thinks it's alive, sends a request through it, finds a dead socket, returns a 502 to the client.
The fix: upstream keep-alive always > downstream idle timeout. If the ALB is 60s, gunicorn keep-alive is 75s. If you bump the ALB to 300s for SSE, bump gunicorn keep-alive to match.
Why it matters: the deploy that kills streams
The scenario: ECS stopTimeout at 30s (default). Gunicorn graceful-timeout at 30s. Race condition: ECS counts 30s and SIGKILLs the container. Gunicorn was also counting 30s for graceful shutdown. SSE workers that were in the middle of closing their streams cleanly get killed mid-frame.
The fix: ECS stopTimeout always greater than gunicorn --graceful-timeout, with margin.
Putting it together: recommended config
Dockerfile / CMD
CMD [ \ "gunicorn", "core.asgi:application", \ "--workers", "2", # 2 × vCPU + 1 (tune per task) "--worker-class", "uvicorn.workers.UvicornWorker", \ "--bind", "0.0.0.0:8000", \ "--forwarded-allow-ips", "*", # trust X-Forwarded-* from the ALB "--timeout", "120", # kills a stuck event loop "--keep-alive", "75", # > ALB idle (60s) "--graceful-timeout", "30", # default is fine "--max-requests", "10000", # rolling restart "--max-requests-jitter", "1000", # don't restart all at once "--preload" # memory savings via CoW ]
Django settings.py
# DB via RDS Proxy (the official ASGI recommendation) DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "HOST": "my-rds-proxy.proxy-xxx.rds.amazonaws.com", "CONN_MAX_AGE": 0, # external pool handles keep-alive "CONN_HEALTH_CHECKS": True, # Django 5+; validates before use # ... rest of the config } }
ECS task definition
{
"stopTimeout": 60, # > gunicorn --graceful-timeout
"essential": true,
// ...
}
ALB target group attributes
idle_timeout = 60s # 300s if you serve SSE on the same ALB deregistration_delay = 30s # time to drain connections on deploy
When you don't need any of this
If you've made it this far, you might have come away thinking a correct Django setup needs a PhD. It doesn't. For plenty of apps, this is more than enough:
CMD ["gunicorn", "core.wsgi", "--workers", "3", "--bind", "0.0.0.0:8000"]
Classic WSGI, no clever tricks. You only need to climb up to ASGI/async if you have at least one of:
- Streaming/SSE/WebSocket that needs to hold a long connection to the client.
- Multiple external API calls in parallel within a single request.
- Voice / real-time where perceived latency is the UX.
- Heavy I/O fan-out like orchestrating 10 internal microservices in one request.
If your app is CRUD over Postgres with sub-50ms queries and templates serving HTML, stay on WSGI. You save complexity and get 10–15ms of latency back.
The mental rule I use today: async is an answer to a concrete problem, not an aesthetic choice. If you can't name the specific problem you're solving by moving to ASGI, the problem probably isn't there — and you're just signing up for free complexity.
The map, in one sentence
WSGI vs ASGI is about how the server talks to your app. Gunicorn vs Uvicorn is about who supervises whom. Threads are where synchronous work actually happens, even under async. Database connections multiply per thread, not per request. Timeouts chain from the outside in, and break silently when they're out of order.
Hold those five sentences in your head the next time something goes wrong in production, and you'll save yourself hours of stack trace.
And remember: the best stack for your case is the one you can debug at 3am when something breaks — not the one in the latest Hacker News benchmark.