skip to content

In Django under ASGI, how do you stream updates from an async view with StreamingHttpResponse and clean up when the client disconnects?

level: seniorimportance: should knowfreq 34%

answer

  1. iterator colour matches the server
  2. async generator as content
  3. what Django cancels
  4. catch, clean up, re-raise

basics

~10 s

Pass an async generator to StreamingHttpResponse. When the client disconnects, Django (5.0+) cancels the coroutine serving the response, so asyncio.CancelledError is raised inside the generator; catch it, release resources, and re-raise.

solid answer

~40 s

Under ASGI, `StreamingHttpResponse` should be given an **async** iterator — typically an `async def` generator that awaits new data and yields chunks, for example server-sent events with `content_type='text/event-stream'`. If the iterator type does not match the server, Django raises a `Warning` and consumes the whole iterator first, which defeats streaming and never finishes for an endless stream. Since Django 5.0 the ASGI handler listens for the `http.disconnect` event; when it arrives Django cancels the coroutine handling the response, so `asyncio.CancelledError` is raised at the generator's current `await`. Catch it (or use `finally`) to release resources, then re-raise. Work done in the view before it returns the response gets the same `CancelledError`.

code

python · 25 lines
python
import asyncio
import logging

from django.http import StreamingHttpResponse

logger = logging.getLogger(__name__)


async def latest_status(shipment_id):
    await asyncio.sleep(0)  # stands in for an awaited lookup
    return 'in_transit'


async def shipment_events(request, shipment_id):
    async def event_stream():
        try:
            while True:
                status = await latest_status(shipment_id)
                yield f'data: {status}\n\n'
                await asyncio.sleep(2)
        except asyncio.CancelledError:
            logger.info('viewer left shipment %s', shipment_id)
            raise

    return StreamingHttpResponse(event_stream(), content_type='text/event-stream')

go deeper

for a junior

Recall that StreamingHttpResponse takes an iterator and that under ASGI it should be an async generator.

for a middle

Explain the iterator-colour rule and why a mismatched iterator is buffered in full with a Warning.

for a senior

Walk through the 5.0 disconnect path to CancelledError, where to put cleanup, and why you re-raise instead of swallowing it.

for a principal

Weigh whether long-lived streams belong in the main Django deployment at all, given what they cost under WSGI and what they need under ASGI.

## The shape of a streaming async view A **streaming response** sends its body in chunks as they are produced instead of building it in memory first. In Django that is `StreamingHttpResponse`, whose content is an iterator. Under **ASGI** the long-lived pattern — long-polling, server-sent events (SSE), progress feeds — is cheap because a waiting stream holds a coroutine, not a thread. The view itself is usually tiny: it validates the request, builds an **async generator**, and returns `StreamingHttpResponse(generator, content_type='text/event-stream')`. The work happens later, while Django iterates the generator and sends each chunk. ## Match the iterator to the server Since 4.2 `StreamingHttpResponse` accepts async iterators, and it records which kind it holds in `is_async`. The rule from the docs is that the iterator type must match the protocol: | Server | Iterator you should pass | If you pass the other kind | |---|---|---| | ASGI | async iterator (`async def` generator) | `Warning`, then the sync iterator is consumed in full via `sync_to_async(list)` before anything is sent | | WSGI | sync iterator | `Warning`, then the async iterator is consumed in full via `async_to_sync` before anything is sent | Full consumption is the trap: the response is buffered, the client sees nothing until the end, and an **infinite** event stream never ends, so the request hangs and memory grows. ## What happens on disconnect Before 5.0 Django did not react to an ASGI client going away. Since **Django 5.0** the ASGI handler listens for the `http.disconnect` message while your view and response run; in 6.1 it does so by running the request inside an `asyncio.TaskGroup` next to a small listener task: 1. The client closes the tab or the proxy drops the connection. 2. The listener receives `http.disconnect` and raises internally, which makes the task group cancel the coroutine that is producing and sending the response. 3. Inside your generator, `asyncio.CancelledError` is raised at whatever `await` it is suspended on. 4. Your `except asyncio.CancelledError:` or `finally:` block runs; you close subscriptions, release a lock, record that the viewer left. 5. You re-raise, so cancellation completes and Django tears the request down. If the view does long work **before** returning the response, the same `CancelledError` is raised in the view body at its current `await`. ## Rules that keep it correct - **Re-raise** `CancelledError`. Catching it and carrying on leaves the coroutine running against a closed connection. - Prefer `finally` for cleanup that must happen on normal completion too. - Keep the generator async all the way down: calling the synchronous ORM inside it trips Django's async-safety guard; use the a-prefixed ORM methods or a sync adapter. - Remember what streaming disables: middleware that needs the whole body (ETag or `Content-Length` generation) cannot work on streaming responses. - Cancellation reaches only code suspended at an `await`. Synchronous work handed to a thread keeps running until it returns. ## What belongs in the cleanup block The cancellation branch is small but should be deliberate: - **release per-connection resources** — unsubscribe from a message channel, close an upstream stream, decrement an in-memory viewer count; - **log the disconnect** at info level, not as an error: a closed tab is normal traffic; - **avoid slow work**: cleanup runs while the task is being cancelled, so a long awaited call there delays teardown; - **avoid the sync ORM**: recording the disconnect in the database still has to go through the async ORM API or a sync adapter. A bounded stream (for example one that ends after the shipment is delivered) should simply `return` from the generator; Django then finishes the response normally and no `CancelledError` is involved. ## Under WSGI None of the cancellation machinery applies under WSGI: Django gets no disconnect event, the response is iterated synchronously, and the streaming connection holds a worker for its whole life. That is why long-lived streams are the textbook case for deploying under ASGI.

  • What changes if the view awaits a slow lookup before it returns the StreamingHttpResponse and the client disconnects then?
    Since 5.0 the cancellation reaches the view body itself: `asyncio.CancelledError` is raised at the `await` the view is suspended on, before any response exists. The docs therefore suggest handling disconnects in the view as well as in the generator when the view does long work up front.
  • Why does an SSE stream behind a synchronous generator appear to hang under ASGI?
    Django sees a sync iterator on an async server, raises a `Warning`, and consumes the whole iterator with `sync_to_async(list)` before sending anything. An endless event loop in the generator never finishes, so nothing reaches the client and the list keeps growing.

saying these in an interview costs you the question

  • Django raises RequestAborted inside your streaming generator on disconnect.
  • Any iterator type streams fine under ASGI; Django converts it lazily.
  • Catching CancelledError and continuing the loop is the clean way to stop.
  • Django detects client disconnects the same way under WSGI.
  • Cancellation interrupts synchronous code already running in a worker thread.