Writing Async Django Middleware¶
Every request passes through every middleware, so middleware decides whether a Django request stays on the event loop or bounces between the loop and threads. Django adapts mismatches automatically: a sync-only middleware in front of an async view makes Django run the middleware in a thread and call back into the async view, and the reverse for async middleware in front of sync views. The adaptation is correct but not free. Measured with Django 6.1.1 on Python 3.14 under one uvicorn worker, issuing requests one at a time so the per-request cost is visible: an async hello-world view served 1,944–1,999 requests per second with no middleware; with three sync-only timing middlewares, 1,468 — about 0.18 ms more per request, a quarter of the throughput; with three async-capable versions of the same middleware, 1,902, within noise of none. At 50 concurrent connections the differences disappeared into run-to-run variation (1,491–1,765 requests per second across all configurations), so this is a latency cost, not a capacity cliff — but every request pays it. This guide writes middleware that runs natively in both modes.
Prerequisites¶
- Django 3.1+ for async middleware (tested on 6.1.1).
- Async views under ASGI, from running Django under ASGI with uvicorn.
- Pure ASGI middleware, for comparison, from writing pure ASGI middleware.
1. Measure what sync-only middleware costs¶
A function-based middleware is sync-only by default. Django checks each middleware's capability flags when it builds the chain and inserts adapters where they do not match:
import time
def timing(get_response): # sync-only: no flags set
def middleware(request):
start = time.perf_counter()
response = get_response(request)
response["X-Elapsed-ms"] = f"{(time.perf_counter() - start) * 1e3:.2f}"
return response
return middleware
With three such middlewares in front of an async view, each request crossed from the event loop into a thread for the middleware, then back to the loop for the view, and back again for the response — sync_to_async and async_to_sync hops. Measured on a single connection: 1,468 requests per second against 1,944–1,999 with no middleware, or about 0.68 ms per request instead of 0.50 ms. A sync view behind the same middleware dropped from 1,656–1,739 to 1,196. The adapters are invisible in normal operation. With DEBUG = True, Django logs Synchronous handler adapted for middleware ... (or Asynchronous handler adapted ... for the reverse) at debug level on the django.request logger while building the chain, which is the easiest way to spot them.
Verify: with DEBUG = True and debug logging for django.request, list every "adapted for middleware" message at startup.
2. Write middleware that supports both modes¶
A class-based middleware can declare both capabilities and pick its implementation when Django builds the chain:
import time
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
class TimingMiddleware:
sync_capable = True
async_capable = True
def __init__(self, get_response):
self.get_response = get_response
if iscoroutinefunction(get_response):
markcoroutinefunction(self) # tell Django this instance is async
def __call__(self, request):
if iscoroutinefunction(self):
return self.__acall__(request)
start = time.perf_counter()
response = self.get_response(request)
response["X-Elapsed-ms"] = f"{(time.perf_counter() - start) * 1e3:.2f}"
return response
async def __acall__(self, request):
start = time.perf_counter()
response = await self.get_response(request)
response["X-Elapsed-ms"] = f"{(time.perf_counter() - start) * 1e3:.2f}"
return response
Django passes an async get_response when the rest of the chain is async; markcoroutinefunction(self) then marks the instance so Django awaits it directly. Measured: three of these in front of an async view served 1,902 requests per second on one connection, indistinguishable from no middleware. Under WSGI the same class runs its sync path with no adapters either. Use asgiref.sync.iscoroutinefunction rather than inspect.iscoroutinefunction, because only the former recognizes objects marked with markcoroutinefunction.
Verify: with debug logging on, no "adapted for middleware" message names your middleware.
3. Use the decorators for simple function middleware¶
For function-style middleware, Django provides decorators that set the flags. An async-only middleware is the simplest when the project only runs under ASGI:
from asgiref.sync import iscoroutinefunction
from django.utils.decorators import async_only_middleware, sync_and_async_middleware
@async_only_middleware
def request_id(get_response):
async def middleware(request):
request.id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
response = await get_response(request)
response["X-Request-ID"] = request.id
return response
return middleware
@sync_and_async_middleware
def request_id_both(get_response):
if iscoroutinefunction(get_response):
async def middleware(request):
...
return await get_response(request)
else:
def middleware(request):
...
return get_response(request)
return middleware
async_only_middleware makes Django adapt sync views behind it instead, so in a project with many sync views it moves the cost rather than removing it; sync_and_async_middleware avoids adaptation in both directions. Request IDs set here should also be stored in a ContextVar for logging, as in propagating request IDs with contextvars, since contextvars follow the request across Django's thread hops.
Verify: the middleware works unchanged under both runserver (WSGI) and uvicorn (ASGI).
4. Keep blocking work out of async middleware¶
Async middleware runs on the event loop for every request, so a blocking call in it stalls every request on the worker. Session and authentication lookups are the usual culprits:
@async_only_middleware
def tenant(get_response):
async def middleware(request):
host = request.get_host().split(":")[0]
request.tenant = await Tenant.objects.aget(domain=host) # async ORM, not .get()
return await get_response(request)
return middleware
Using the sync ORM here raises SynchronousOnlyOperation, which at least fails loudly; other blocking calls — a synchronous Redis client, a file read, requests — fail silently by stalling the loop. Cache lookups that run on every request deserve an in-process cache in front of them, as in building an async TTL cache decorator, because they multiply by every request. Django's own authentication middleware supports async: in async code, use await request.auser() rather than request.user, which would trigger a lazy, blocking session lookup.
Verify: asyncio debug mode, run in staging, reports no slow callbacks attributed to middleware.
5. Keep streaming responses intact¶
Middleware that reads or rewrites the response body breaks streaming responses, and middleware that wraps an async streaming body with a sync iterator forces it to be buffered:
class SecurityHeaders:
sync_capable = async_capable = True
...
async def __acall__(self, request):
response = await self.get_response(request)
response["X-Content-Type-Options"] = "nosniff" # headers only: safe for streams
if not response.streaming:
response["Content-Length"] = str(len(response.content))
return response
Check response.streaming before touching .content: StreamingHttpResponse has no content attribute, and code that wraps response.streaming_content must preserve its type — an async iterator under ASGI, as covered in streaming responses from async Django views, where a type mismatch buffered a two-second stream completely. Compression middleware is the common offender; Django's GZipMiddleware handles both iterator types, but third-party middleware may not.
Verify: a streaming endpoint still delivers its first chunk within milliseconds with the full middleware stack enabled.
Verification¶
Middleware stays out of the way when:
- No "adapted for middleware" debug messages appear for the project's own middleware.
- Middleware declares both capabilities (or async-only in ASGI-only projects).
- Async middleware uses async I/O only, including
await request.auser(). - Body-touching middleware checks
response.streamingand preserves iterator types.
Diagnostic Hook: add one sequential-request benchmark of a trivial async view to CI, with the full middleware stack. A drop of more than a few percent after a dependency upgrade usually means a newly added or upgraded middleware is sync-only and is adding thread hops to every request.
Pitfalls & edge cases¶
- Function middleware without decorators. It is sync-only; measured 0.18 ms extra per async request for three.
inspect.iscoroutinefunction. It misses objects marked withmarkcoroutinefunction.request.userin async middleware. It triggers a lazy blocking session lookup; useauser().- Reading
.contenton streaming responses. It does not exist; checkresponse.streaming.
Frequently Asked Questions¶
How do I write async middleware in Django?
Declare sync_capable = True and async_capable = True on a class, mark the instance with markcoroutinefunction when get_response is a coroutine function, and implement an async acall; or decorate a function factory with @async_only_middleware or @sync_and_async_middleware.
Does sync middleware slow down async Django views?
Yes, slightly: Django adds thread hops to adapt it. Three sync-only middlewares took a single-connection benchmark from about 1,970 to 1,468 req/s — roughly 0.18 ms per request — while async-capable versions measured 1,902.
How do I find which middleware Django is adapting?
With DEBUG = True and debug logging on the django.request logger, Django logs 'Synchronous handler adapted for middleware ...' or 'Asynchronous handler adapted for middleware ...' at startup for each mismatch.
Can async middleware wrap StreamingHttpResponse?
Yes, if it only touches headers or preserves the async iterator in streaming_content; check response.streaming before reading content.
Related¶
- Django Async — up to the topic overview.
- Writing async views in Django — the views behind the middleware.
- Network I/O & Protocol Handling — the section overview.