skip to content

How does Django's MiddlewareMixin run under ASGI, and why do its process_request and process_response hooks still hop into a thread?

level: seniorimportance: nice to knowfreq 20%

answer

  1. both flags already True
  2. decided in __init__
  3. __call__ hands off to __acall__
  4. hooks wrapped, get_response awaited

basics

~20 s

Django's MiddlewareMixin is sync- and async-capable; under ASGI call returns acall, which awaits get_response directly but runs each sync process_request and process_response hook through sync_to_async, so it hops per hook instead of holding a thread.

solid answer

~30 s

`MiddlewareMixin` sets `sync_capable = True` and `async_capable = True`. Its `__init__` records `async_mode = iscoroutinefunction(get_response)` and, in async mode, calls `markcoroutinefunction(self)`. `__call__` then returns `self.__acall__(request)` in async mode. `__acall__` assumes the hooks are synchronous: it runs `process_request` and `process_response` each through `sync_to_async(..., thread_sensitive=True)`, but awaits `get_response` on the event loop. So bundled middleware never holds a thread while the view runs — only one short hop per defined hook. To avoid even that, override `__acall__` with native async code, as `RemoteUserMiddleware` does with `aprocess_request`; overriding `__call__` without the `async_mode` check breaks the async path.

code

python · 14 lines
python
from django.utils.deprecation import MiddlewareMixin


class ServerTimingMiddleware(MiddlewareMixin):
    # Inherits sync_capable = True and async_capable = True.
    # Under ASGI each hook below runs via sync_to_async(thread_sensitive=True);
    # get_response itself is awaited on the event loop.

    def process_request(self, request):
        request.timing_marks = []

    def process_response(self, request, response):
        response.headers["X-Timing-Marks"] = str(len(request.timing_marks))
        return response

go deeper

for a junior

Know that most of Django's bundled middleware subclasses MiddlewareMixin and that it works under both WSGI and ASGI.

for a middle

Explain the async_mode decision in init, the markcoroutinefunction call and how call hands off to acall in async mode.

for a senior

Contrast the per-hook thread hop with a sync-only middleware's thread held across the view, and know when to override acall with native async code.

for a principal

Judge when per-hook hops are acceptable versus investing in fully async middleware, based on how long-lived and I/O-bound the ASGI traffic is.

## Where MiddlewareMixin comes from `django.utils.deprecation.MiddlewareMixin` began as the bridge from the old `MIDDLEWARE_CLASSES` style (removed long ago; the setting today is `MIDDLEWARE`) to the factory protocol. It still earns its place: nearly all of Django's bundled middleware subclasses it — `SecurityMiddleware`, `SessionMiddleware`, `CommonMiddleware`, `CsrfViewMiddleware`, `AuthenticationMiddleware`, `MessageMiddleware`, `GZipMiddleware`, `LocaleMiddleware` and others. A subclass writes only hook methods, typically `process_request(request)` and `process_response(request, response)`, and the mixin supplies `__init__` and `__call__`. Because the mixin is async-capable, moving a project to ASGI does not by itself put adapters around Django's bundled stack; the adapters come from custom or third-party middleware that never declared async support. ## It is async-capable The mixin sets both flags on the class: - `sync_capable = True` - `async_capable = True` So Django never wraps a `MiddlewareMixin` subclass in an adapter at the chain level: under WSGI it runs sync, and under ASGI (with async-capable neighbours) it runs async. ## How it switches mode The switch happens in the constructor and in `__call__`: 1. `__init__(get_response)` stores `get_response` and sets `self.async_mode = iscoroutinefunction(get_response)`. 2. If `async_mode` is true, it calls `markcoroutinefunction(self)` so the **instance** is recognised as a coroutine function by whatever wraps it. It does not replace `__call__` itself; the comment in the source says the actual switch is done inside `__call__` to avoid swapping out dunder methods. 3. `__call__(request)` checks `self.async_mode`. In sync mode it runs `process_request`, then `get_response` if no response came back, then `process_response`. In async mode it returns `self.__acall__(request)`, a coroutine the caller awaits. ## What __acall__ does `__acall__` is the async twin of `__call__`: ```python async def __acall__(self, request): response = None if hasattr(self, "process_request"): response = await sync_to_async( self.process_request, thread_sensitive=True )(request) response = response or await self.get_response(request) if hasattr(self, "process_response"): response = await sync_to_async( self.process_response, thread_sensitive=True )(request, response) return response ``` The important detail: the **hooks** are assumed to be synchronous and each one is run through `sync_to_async(..., thread_sensitive=True)`, while the **downstream call** `get_response` is awaited directly on the event loop. ## The cost profile | | Sync-only middleware under ASGI | `MiddlewareMixin` subclass under ASGI | |---|---|---| | Chain-level adapter | Yes (`async_to_sync` around its `get_response`) | No | | Thread held while the view runs | Yes | No | | Thread hops per request | Into sync and back around the whole downstream call | One short hop per defined hook | | Middleware outside it forced into sync mode | Sync-capable ones, yes | No | So the mixin is the cheap way to be ASGI-friendly: it never holds a thread across the view, which is what matters for long-lived async requests. It is not free — each `process_request` and `process_response` costs a thread hop — but those hops are short, and the mixin's source comment names the goal: not consuming a thread during a whole request. ## Pitfalls and how to go fully async - **Overriding `__call__`** in a subclass without reproducing the `async_mode` check throws the async path away; the class still says `async_capable = True`, so Django will not adapt it, and the sync body receives a coroutine `get_response`. - **Declaring a hook with `async def`** does not help: `__acall__` runs `process_request` and `process_response` through `sync_to_async`, which is built for sync callables. - **Blocking work inside a hook** runs in a thread via `sync_to_async`, so it does not block the event loop, but it still costs a hop on every request; heavy hooks deserve an async design. - To avoid the per-hook hop entirely, override `__acall__` with genuinely async code, as Django's own `RemoteUserMiddleware` does: it keeps a sync `process_request` for WSGI and an `aprocess_request` awaited from its `__acall__` for ASGI (it implements its own `__init__` with `markcoroutinefunction` rather than subclassing the mixin). ## The other hooks `process_view` and `process_template_response` are not routed through `__acall__`; Django collects them separately and adapts each one to the request's mode (sync hooks are wrapped with `sync_to_async` under ASGI). `process_exception` is always adapted to **sync**, because Django's exception-handling stack is still synchronous. ## Summary `MiddlewareMixin` is async-capable, marks instances with `markcoroutinefunction`, and in async mode dispatches to `__acall__`, which hops each sync hook into a thread but awaits the rest of the chain directly.

  • What goes wrong if a MiddlewareMixin subclass in Django overrides __call__ with a plain synchronous body?
    The class still inherits `async_capable = True`, so under ASGI Django does not adapt it and passes a coroutine `get_response`. The overridden `__call__` skips the `async_mode` dispatch, calls `get_response` without awaiting and returns a coroutine instead of a response. Keep to hooks or reproduce the `__acall__` hand-off.
  • Under ASGI, how does Django treat a middleware's process_view and process_exception hooks?
    They are not run through `__acall__`. Django collects them separately and adapts each: `process_view` and `process_template_response` to the request's mode (a sync one gets `sync_to_async` under ASGI), while `process_exception` is always adapted to sync, since Django's exception-handling stack is still synchronous.

A receptionist who walks each form to the back office and returns: every form costs a short trip, but the receptionist never sits in the back office waiting while the visitor's meeting runs.

saying these in an interview costs you the question

  • MiddlewareMixin middleware is sync-only and Django adapts it under ASGI
  • MiddlewareMixin holds a thread for the whole request under ASGI
  • Declaring process_request as async def makes the mixin await it natively
  • Overriding __call__ in a mixin subclass keeps the async path intact