After moving a Django project to ASGI with async views, every request still holds a thread and the log says 'Asynchronous handler adapted for middleware'; what is happening and how do you fix it?
answer
- one middleware lacks a flag
- chain built inner to outer
- async_to_sync blocks a thread
- outer neighbours drop to sync
- django.request debug log names it
basics
~20 sA sync-only middleware sits in the Django ASGI stack, so Django runs it and every sync-capable middleware outside it in a thread and reaches the async view through async_to_sync, holding that thread per request. Make it hybrid.
solid answer
~40 sDjango builds the chain from the view outward; under ASGI the inner handler is async, and a middleware with the default flags (`sync_capable=True`, `async_capable=False`) forces Django to wrap its `get_response` in `async_to_sync`. Every sync-capable middleware outside it then also runs sync, to minimise switches, and the top is wrapped in `sync_to_async(thread_sensitive=True)`. So each request enters a thread, runs the sync middleware, and hops back to the loop for the view while that thread waits — capping concurrency for SSE, long-polling or upstream fan-out. With `DEBUG = True` and `django.request` at DEBUG, Django logs which middleware it adapted. Fix it by making that middleware hybrid (both flags, branch on `iscoroutinefunction(get_response)`) or replacing it; reordering does not remove the held thread.
code
python · 14 lines# settings.py (development only) - make Django report middleware adaptation
DEBUG = True
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django.request": {"handlers": ["console"], "level": "DEBUG"},
},
}
# Startup output names each adapted middleware, e.g.:
# Asynchronous handler adapted for middleware audit.middleware.AuditLogMiddleware.go deeper
Remember that a middleware without async support forces Django to switch into a thread under ASGI, and that Django can log which middleware it adapted.
Walk through how load_middleware picks a mode for each middleware from the handler beneath it, and where async_to_sync and sync_to_async get inserted.
Diagnose from the django.request debug lines, explain why sync-capable neighbours drop to sync and why reordering fails, then ship a hybrid rewrite and re-measure.
Decide whether ASGI was worth adopting for this workload at all; ORM-bound views gain little, and a fully async middleware stack is a maintained commitment.
## The symptom A team moves a Django project from a WSGI server to an ASGI server and rewrites its slow endpoints (upstream HTTP fan-out, server-sent events, long-polling) as `async def` views. Load tests show each in-flight request still ties up a thread, and concurrency tops out near the thread pool's size. With `DEBUG = True` and debug logging for the `django.request` logger, startup prints something like: `Asynchronous handler adapted for middleware audit.middleware.AuditLogMiddleware.` That line is the diagnosis: one middleware in `MIDDLEWARE` is **sync-only**, and Django had to adapt the stack around it. ## How Django builds the chain When the ASGI handler starts it calls `load_middleware(is_async=True)`. Django walks `MIDDLEWARE` **from the innermost entry outward**, starting from an async request handler, and chooses a mode for each middleware: 1. If the handler below is sync and the middleware is `sync_capable`, the middleware runs **sync**. 2. Otherwise it runs in whatever mode `async_capable` allows. 3. If the chosen mode differs from the handler below, Django wraps that handler: `async_to_sync` when a sync middleware sits on an async handler, `sync_to_async(..., thread_sensitive=True)` in the opposite case. 4. At the top, the whole chain is adapted to the server's mode. A middleware with no flags is `sync_capable = True`, `async_capable = False`: sync-only. ## What that does to one request Suppose `AuditLogMiddleware` sits in the middle of an otherwise async-capable stack: - Everything **inside** it (closer to the view) runs async. - `AuditLogMiddleware` runs sync; its `get_response` is the async inner chain wrapped in `async_to_sync`. - Everything **outside** it that is `sync_capable` now also runs **sync** — rule 1 — because Django minimises the number of switches. Bundled middleware based on `MiddlewareMixin` is sync-capable, so it follows. - The top of the stack is wrapped in `sync_to_async(thread_sensitive=True)`. Per request, the event loop hands the request to a thread, the outer middleware and the audit middleware run there, then `async_to_sync` hops back to the event loop for the async view while **that thread waits** for the answer. The Django docs add that Django holds the sync thread open for middleware exception propagation. The async view runs, but the request still owns a thread for its whole duration. | Stack | Thread held per request | Switches | |---|---|---| | ASGI, every middleware async-capable, async view | No | None in the middleware stack | | ASGI, one sync-only middleware, async view | Yes, for the whole downstream call | Into sync and back to async | | ASGI, all middleware and views sync | Yes | One, before entering the stack | | WSGI | Yes (the whole request is handled synchronously) | None, provided no middleware is async-only | For ORM-bound request/response views this is rarely a meaningful penalty; the docs say so. It matters when ASGI was chosen **for in-process concurrency over non-ORM I/O**, because a thread per request caps exactly the concurrency the move was meant to buy. ## Finding and fixing it 1. **Find it.** Set `DEBUG = True` in a non-production environment and give the `django.request` logger level `DEBUG`. Django logs "Asynchronous handler adapted for middleware X" when sync middleware X had to wrap an async handler, and "Synchronous handler adapted for middleware X" in the reverse case. These lines are emitted only when `DEBUG` is on. 2. **Fix the middleware.** Make it hybrid: set both flags (or `sync_and_async_middleware` on a function factory) and branch on `inspect.iscoroutinefunction(get_response)`, with an `async def` path that awaits `get_response`. For class-based middleware, mark the instance with `inspect.markcoroutinefunction(self)` in async mode, or subclass `MiddlewareMixin` and keep to `process_request` / `process_response`. 3. **Third-party middleware.** Check whether a newer release declares async support; otherwise wrap or replace it. 4. **Re-verify.** Restart, confirm no "adapted" lines remain, and re-run the load test. What does **not** fix it: - **Reordering.** Wherever the sync-only middleware sits, the downstream async work is reached through `async_to_sync`, so a thread is held for it; moving it only changes how many neighbours run sync. - **Setting `async_capable = True` without an async code path.** Django stops adapting, and the layer above receives a coroutine or blocks the event loop. - **Adding worker threads.** That raises the ceiling without removing the per-request thread. ## Summary One sync-only middleware in an ASGI stack forces a thread onto every request that crosses it, drags sync-capable middleware outside it into sync mode, and puts a sync/async switch on both sides. The `django.request` debug log names it; making it hybrid removes it.
- Would moving the sync-only middleware to the end of Django's MIDDLEWARE list remove the thread cost?No. Wherever it sits, the async work below it is reached through `async_to_sync`, so a thread waits for the downstream call. Moving it changes only how many sync-capable neighbours outside it are pulled into sync mode. The fix is an async-capable implementation.
- If a Django project runs under ASGI but every middleware and view is synchronous, how many sync/async switches happen per request?Django switches once, before entering the middleware stack: the whole chain runs sync inside a thread, and no further adaptation happens between middleware. That is why a fully sync project can run under ASGI at modest cost, though it gains little concurrency from it.
saying these in an interview costs you the question
- Moving the sync middleware to a different position removes the thread cost
- Setting async_capable = True on the class is enough to fix it
- Only the one sync middleware runs in a thread; its neighbours stay async
- The adaptation log lines appear in production with DEBUG = False
- Adding worker threads removes the per-request thread