Streaming Responses from Async Django Views¶
StreamingHttpResponse sends a response body as a generator produces it: server-sent events, large CSV exports, progress logs, relayed LLM tokens. In async Django the generator can be synchronous or asynchronous, and the server can be ASGI or WSGI — and whether the response actually streams depends on matching the two. Measured with Django 6.1.1 on Python 3.14, using a generator that produced 200 events 10 ms apart: under uvicorn (ASGI), an async generator delivered its first event after 1 ms, and when the client disconnected after 20 events the generator was cancelled having produced 20. A sync generator under the same server delivered nothing for 2.02 s, then everything at once, and produced all 200 events even after the client left — with the warning StreamingHttpResponse must consume synchronous iterators in order to serve them asynchronously. Under gunicorn (WSGI) the behaviour reversed: the sync generator streamed in 1 ms and stopped within about two events of the disconnect, while the async one was buffered for 2.02 s and ran to completion. This guide makes streams stream under the server you run.
Prerequisites¶
- Django 4.2+ for async iterators in
StreamingHttpResponse(tested on 6.1.1). - An ASGI deployment, from running Django under ASGI with uvicorn.
- Server-sent events, from streaming server-sent events from asyncio.
1. Use an async generator under ASGI¶
Under an ASGI server, give StreamingHttpResponse an async iterator. Django sends each chunk as it is produced:
import asyncio
from django.http import StreamingHttpResponse
async def events(request):
async def gen():
for i in range(200):
yield f"data: {i}\n\n"
await asyncio.sleep(0.01) # stands in for waiting on real events
return StreamingHttpResponse(gen(), content_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
Measured: the first event reached the client 1 ms after the request, and all 200 arrived over 2.03 s, as produced. The generator runs on the event loop between chunks, so it must not block; database reads inside it use the async ORM or sync_to_async, as in using the async Django ORM. The headers tell intermediate proxies not to cache or buffer a live stream.
Verify: under the production server and proxy path, the first chunk arrives before the generator finishes.
2. Do not pass sync generators under ASGI¶
A sync generator under an ASGI server cannot be iterated on the event loop without blocking it, so Django consumes it entirely in a thread and then sends the result:
def export_csv(request):
def rows():
yield "id,title\n"
for book in Book.objects.iterator(chunk_size=2000): # sync iteration
yield f"{book.id},{book.title}\n"
return StreamingHttpResponse(rows(), content_type="text/csv")
Measured with the 200-event generator: no data for 2.02 s, then everything, plus Warning: StreamingHttpResponse must consume synchronous iterators in order to serve them asynchronously. Use an asynchronous iterator instead. For a large export that means the whole file is held in memory before the first byte is sent — the opposite of streaming — and the client's timeout clock runs the whole time. Convert the generator to async (async for over the queryset, or aiterator) to restore streaming. The warning is easy to miss in logs, so treat it as an error in tests.
Verify: run the test suite with -W error::Warning for Django's streaming warnings, or check logs for the "must consume" message after deploys.
3. Let disconnects cancel the generator¶
Under ASGI, Django watches for the client disconnecting while it streams. When it does, Django cancels the task iterating the async generator, which raises CancelledError at the generator's current await:
async def events(request):
async def gen():
subscription = await bus.subscribe("orders")
try:
async for event in subscription:
yield f"data: {event.json()}\n\n"
finally:
await subscription.close() # runs on disconnect
return StreamingHttpResponse(gen(), content_type="text/event-stream")
Measured: a client that left after 20 events caused exactly one cancellation, and the generator had produced 20 events — nothing more ran. That is what makes long-lived streams safe: subscriptions, upstream HTTP streams and database cursors held by the generator are released through its finally. Do not catch CancelledError without re-raising it inside the generator, or the cleanup path and the cancellation both break, as described in preventing CancelledError leaks in cleanup. The relay version of this, with an LLM stream as the source, is in relaying LLM token streams through FastAPI; the same rule — the generator owns the upstream — applies in Django.
Verify: close a client mid-stream and confirm the generator's finally block ran (a log line or a counter).
4. Under WSGI, stream with sync generators¶
If the project runs under gunicorn's sync workers, the rules invert. A sync generator streams naturally, and an async one is consumed completely first:
# WSGI deployment: a sync generator streams
def export_csv(request):
def rows():
yield "id,title\n"
for book in Book.objects.only("id", "title").iterator(chunk_size=2000):
yield f"{book.id},{book.title}\n"
return StreamingHttpResponse(rows(), content_type="text/csv")
Measured under gunicorn: the sync generator's first event arrived after 1 ms, and when the client left after 20 events the generator stopped at about 22 — the worker noticed on its next failed write. The async generator was buffered for 2.02 s with StreamingHttpResponse must consume asynchronous iterators in order to serve them synchronously. The catch with sync streaming under WSGI is capacity: each open stream occupies a whole worker for its duration, which is why long-lived streams such as server-sent events are better served from an ASGI deployment, where an open stream costs only a suspended task.
Verify: under WSGI, no view returns an async iterator to StreamingHttpResponse; under ASGI, no view returns a sync one.
5. Write code that streams under both¶
Libraries and projects mid-migration may run under either server. A view can check isinstance(request, ASGIRequest) (from django.core.handlers.asgi), but response bodies are often built in helpers that never see the request, so an explicit per-deployment setting is usually clearer:
from django.conf import settings
def stream_rows(qs, render):
if settings.ASGI_DEPLOYMENT: # your own setting, set per deployment
async def agen():
async for obj in qs.aiterator(chunk_size=2000):
yield render(obj)
return agen()
def gen():
for obj in qs.iterator(chunk_size=2000):
yield render(obj)
return gen()
def export_csv(request):
return StreamingHttpResponse(stream_rows(Book.objects.only("id", "title"),
lambda b: f"{b.id},{b.title}\n"),
content_type="text/csv")
The view itself can stay synchronous: it only builds the response, and Django runs it fine under either server. The body's generator is the part that must match. Either way, a test per deployment type that asserts the first chunk arrives quickly catches a mismatch before users do. The broader migration path is in mixing sync and async views in Django.
Verify: the same export endpoint streams its first chunk within milliseconds under both server types in CI.
Verification¶
Django streaming responses work when:
- Async generators are used under ASGI and sync generators under WSGI.
- No "must consume" warnings appear in logs or tests.
- Disconnects cancel async generators, and their
finallyblocks release resources. - Long-lived streams run on ASGI, where an open stream does not occupy a worker.
Diagnostic Hook: measure time to first byte for every streaming endpoint in synthetic monitoring. A first byte that arrives at roughly the full response duration is the signature of a buffered stream — almost always a generator type that does not match the server.
Pitfalls & edge cases¶
- Sync generators under ASGI. Measured: buffered for 2.02 s, and all 200 events produced after the client left.
- Async generators under WSGI. Measured: buffered for 2.02 s.
- Swallowing
CancelledError. The generator keeps running after the client is gone. - Long streams on sync workers. Each one holds a worker for its whole duration.
Frequently Asked Questions¶
Why does my Django StreamingHttpResponse not stream?
The generator type does not match the server: under ASGI a sync generator, or under WSGI an async one, is consumed completely before sending. In testing that buffered a 2-second stream entirely and logged a 'must consume ... iterators' warning.
Does Django stop a streaming response when the client disconnects?
Under ASGI with an async generator, yes: Django cancelled the generator after the client left at 20 events. A sync generator under ASGI ran to completion; under WSGI a sync generator stopped within about two events.
How do I stream server-sent events from Django?
Return StreamingHttpResponse with an async generator and content_type text/event-stream from a view served by an ASGI server, and set Cache-Control: no-cache and X-Accel-Buffering: no.
Can a sync Django view return an async streaming generator?
Yes; the view only constructs the response. What matters is that the body's iterator type matches the server.
Related¶
- Django Async — up to the topic overview.
- Writing async Django middleware — middleware that does not break streams or add thread hops.
- Network I/O & Protocol Handling — the section overview.