A Django async view calls three shipping-rate APIs concurrently; what does it gain under WSGI, and what more under ASGI?
answer
- latency versus throughput
- one-off event loop per request
- who waits while you await
- sync middleware in the path
basics
~20 sUnder both, asyncio.gather overlaps the three calls, so that request takes roughly as long as the slowest one. Only under ASGI does the process serve other requests while it awaits; under WSGI it holds a worker for the whole request.
solid answer
~40 sUnder a WSGI server Django wraps an async view with `async_to_sync`, so it runs in its own one-off event loop on the worker thread. Concurrency *inside* the request works: `asyncio.gather` overlaps the three rate calls and latency drops to about the slowest one. But the worker is busy until the view returns. Under ASGI the view runs on the server's event loop, so while it awaits the carriers, the same process can serve other requests without extra threads — that is the throughput gain, and it is what makes long-polling and streaming cheap. The gain shrinks if synchronous middleware sits in the path: Django runs it in a thread per request, and the ORM's sync work still happens on threads.
code
python · 17 linesimport asyncio
from django.http import JsonResponse
CARRIERS = ('parcelco', 'swiftship', 'northpost')
async def fetch_rate(carrier, weight_kg):
# stands in for an awaited call made with an async HTTP client
await asyncio.sleep(0.3)
return {'carrier': carrier, 'price': round(4.5 + weight_kg, 2)}
async def shipping_rates(request):
weight_kg = float(request.GET.get('kg', '1'))
rates = await asyncio.gather(*(fetch_rate(c, weight_kg) for c in CARRIERS))
return JsonResponse({'rates': sorted(rates, key=lambda r: r['price'])})go deeper
Remember the headline: async views run under WSGI too, but the real concurrency gain needs an ASGI deployment.
Separate per-request latency from process throughput, and explain the one-off event loop Django creates for an async view under WSGI.
Show how sync middleware, sync views and thread-bound ORM work erode the ASGI gain, and how you would find the adapted middleware.
Argue when ASGI is worth its operational change: long-lived or fan-out-heavy endpoints, not CRUD pages that mostly wait on the database.
## Two different gains An async view can buy two different things, and interviewers want to hear them separated: - **Latency of this request** — overlapping independent I/O inside one request, so three 300–500 ms carrier calls cost roughly 500 ms instead of 1.2 s. - **Throughput of the process** — letting one process keep many requests in flight while each is waiting, without one OS thread per request. The first is available under **both** WSGI and ASGI. The second exists only under **ASGI**. ## Under WSGI: a one-off event loop A **WSGI** server calls Django synchronously on a worker (a process or thread). When the resolved view is a coroutine function, Django's synchronous request handler wraps it with `asgiref`'s `async_to_sync`, and the docs describe the effect: the async view "will run in their own, one-off event loop." 1. The worker thread enters the view. 2. An event loop is created for this call; `asyncio.gather` starts the three carrier requests together. 3. The loop waits for all three; nothing else is scheduled on it. 4. The view returns, the loop is discarded, and only then is the worker free. So the fan-out still pays off — the request is faster — but the worker was occupied the whole time, exactly as with a sync view. There is also a small per-request adaptation cost, and long-lived requests (streams, long-polls) still tie up a worker each. ## Under ASGI: the server's loop Under an **ASGI** server Django's request handler is itself a coroutine. An async view is simply awaited on the server's loop. While it awaits the carriers, the loop runs other requests' code. This is what the docs mean by servicing "hundreds of connections without using Python threads". The gain is only as large as the async path is clean: | On the path | What Django does under ASGI | Effect on concurrency | |---|---|---| | Async view, async-capable middleware only | Awaits everything on the loop | Full benefit | | Sync middleware between server and view | Runs it in a thread, holds that thread for exception propagation | A thread per in-flight request caps concurrency | | Sync view | Wraps it in `sync_to_async(thread_sensitive=True)` inside a per-request thread context | Works; behaves like threaded sync code | | ORM calls from the async view | a-prefixed methods delegate to the sync ORM on a thread | DB work is still thread-bound | Django's bundled middleware supports both modes; third-party middleware may not. The docs suggest turning on debug logging for the `django.request` logger and looking for "Asynchronous handler adapted for middleware" messages to find the culprits. ## Putting it together for the three carriers - **WSGI + async view**: this request gets faster; the site's capacity does not change, because each request still holds a worker. - **ASGI + async view + async-clean middleware**: this request gets faster *and* other requests run while it waits, so the same process sustains more in-flight rate lookups. - **ASGI + sync view that loops over the three carriers**: no gain at all; the calls run one after another on a thread. The docs are candid that you should measure: some purely synchronous codebases see a small change under ASGI, and "in general you will only want to enable ASGI mode if you have asynchronous code in your project." ## What not to claim - An async view does **not** turn blocking calls into non-blocking ones; a blocking HTTP library call inside it stalls the loop. - Switching the deployment to ASGI does **not** make sync views async. - Under WSGI, Django does **not** reject async views; it adapts them. ## A worked timing example Suppose the three carriers answer in 300, 400 and 500 ms and the process has eight worker threads. 1. **Sync view, calls in sequence** — about 1.2 s per request; eight requests in flight at most. 2. **Async view under WSGI** — about 0.5 s per request, because the calls overlap; still eight requests in flight at most, each holding its worker for the whole 0.5 s. 3. **Async view under ASGI, async-clean path** — about 0.5 s per request, and hundreds of requests can be waiting at once, because waiting holds a coroutine rather than one of the eight threads. The second row is why teams sometimes see a latency win right after rewriting views, before any server change; the third row is the capacity win they were actually after.
- Under ASGI, how does Django run an ordinary sync view?The async request handler wraps it with `sync_to_async(thread_sensitive=True)`, and the ASGI handler opens a `ThreadSensitiveContext` per request, so the view runs on a thread tied to that request. It works unchanged, with a small adaptation cost, but it gains nothing from ASGI: each in-flight sync view still occupies a thread.
- Would a sync view with a thread pool fanning out the three calls be just as good?For latency, largely yes: three threads overlap the calls under either server. The difference is capacity. Under ASGI an async view waits without holding a thread, so thousands of in-flight lookups cost coroutines, not threads. Under WSGI both designs hold a worker per request, so the async version buys little over a small thread pool there.
A courier depot with one clerk per customer: under WSGI the clerk phones three carriers at once and gets the answer faster, but still serves nobody else meanwhile; under ASGI one clerk juggles many customers while all the phones ring.
saying these in an interview costs you the question
- Under WSGI an async view is rejected or silently run sequentially.
- asyncio.gather inside a view only overlaps calls when deployed under ASGI.
- Deploying under ASGI makes every existing sync view non-blocking.
- Sync middleware under ASGI is skipped for async views.
- Async views make blocking HTTP client calls non-blocking automatically.