Django · Async · Performance

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.

· · ·
Chapter 01

What WSGI and ASGI are, with no mystery

TL;DR
WSGI is a restaurant where each waiter handles one table at a time. ASGI is the same restaurant where each waiter can juggle several tables at once — as long as the work is "wait for food", not "chop onions". For an app that does a lot of I/O (database, external APIs, streaming), ASGI wins. For fast CRUD on a fast database, WSGI is still excellent.

WSGI and ASGI are protocols. They define how the server (gunicorn, uvicorn) talks to your application (Django). It's a contract — nothing more.

✦ Restaurant analogy

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:

~15ms
Extra ASGI overhead
vs WSGI per simple request
2–4×
ASGI throughput on
concurrent I/O-bound workloads
~100×
Concurrent connections one
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

✓ ASGI shines
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.

✓ WSGI is still great
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.

✗ ASGI doesn't help
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.

✗ WSGI suffers
Long-lived connections

Streaming, voice UX, real-time. WSGI keeps a worker pinned for the entire connection. ASGI is effectively required.

Chapter 02

Gunicorn and Uvicorn: who does what

TL;DR
Gunicorn is the manager. Uvicorn is the waiter. When you run 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:

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.

Chapter 03

Threads: where most people get lost

TL;DR
In sync Django, it's simple: 1 thread per worker, 1 request per thread, 1 connection per thread. In async Django, it's multi-layered: threads still exist, just hidden inside the 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.

Sync Worker · 1 Python process
Thread A → Request 1 (start to finish) 1 DB connection
Thread B → Request 2 (start to finish) 1 DB connection
Thread C → Request 3 (start to finish) 1 DB connection
Result: 1 connection per thread. The same thread reuses its connection on the next request (this is what 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.

Pure async Worker · 1 process, 1 event loop
Event loop accepts hundreds of concurrent requests
Thread sensitive master → Runs all ORM queries, from all requests 1 DB connection
Result: 1 thread, 1 connection for the whole worker. Hundreds of concurrent requests share it. Zen.

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:

From the Django docs
If you put synchronous middleware between an ASGI server and an async view, it has to switch into sync mode for the middleware and back into async for the view. Django also keeps the sync thread open to propagate exceptions. This can add the penalty of a thread per request.

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.

Async + sync middleware Worker · the actual production scenario
Event loop + asgiref thread pool (up to ~40)
Thread sensitive → Async ORM (pure queries) 1 DB connection
Thread A → Request 1 (held by middleware) +1 connection
Thread B → Request 2 (held by middleware) +1 connection
Thread C → Request 3 (held by middleware) +1 connection
· · ·
Thread N → Every concurrent request opens a new thread +1 connection
Result: 1 "shared" connection + N connections (one per concurrent request). At a peak of 50 concurrent requests, ~51 open connections per worker.

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.

Chapter 04

Database connections: the gotcha

TL;DR
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:

T=0 Request 1 lands, picks up thread A → thread A opens connection 1 to the database T=2 Request 1 finishes → thread A returns to the pool with connection 1 alive T=3 Request 2 lands (concurrent with request 1, which was still in flight) → picks up thread B (new), opens connection 2 T=5 Request 7 lands → picks up thread A again, reuses connection 1 ✓ T=10 50 concurrent requests in flight → ~50 threads busy, ~50 connections open T=70 Some threads idle, but their connections still alive → connections only close after 60s without use Result: up to ~50 open connections, per worker. 2 workers × 50 connections = 100 connections. An RDS db.t3.medium handles ~85. CRASH.

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:

From the Django docs
"When using ASGI, persistent connections should be disabled. Instead, use your database backend's built-in connection pooling if available." In other words: under ASGI the recommended posture is 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.

A gotcha worth knowing
RDS Proxy has a default 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.
Chapter 05

The timeouts that chain

TL;DR
There are five different timeouts in your stack, and they need to line up in a specific order. Confusing them gives you random 502s, requests cut short on deploy, and zombie workers. Rule of thumb: every outer layer waits longer than the one inside it.

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:

# Idle chain (keeping connections alive): gunicorn --keep-alive (75s) > ALB idle timeout (60s) > ... # Shutdown chain (deploy, scale-in): ECS stopTimeout (60s) > gunicorn --graceful-timeout (30s) > real cleanup time # Detection chain (stuck worker): gunicorn --timeout (120s) > app's own turn cap (60s) > real time

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.

Chapter 06

Putting it together: recommended config

TL;DR
A sane config for Django async on ECS Fargate behind an ALB, no paranoia. Works for ~95% of cases.

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
Chapter 07

When you don't need any of this

TL;DR
Honesty: most Django apps don't have any of this and run fine. Async + ASGI + careful tuning only pays off for specific workloads. Knowing when NOT to step into that snake pit is part of the knowledge.

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:

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.