SSE in Django with Async Views Permalink to this section

Part of Python & FastAPI SSE Implementation Guide, under Backend Stream Generation & Connection Management.

Django can serve Server-Sent Events well, but only in one configuration: an async view returning a StreamingHttpResponse over an async iterator, running under an ASGI server. Any other combination — a sync generator, WSGI, or a middleware that touches the response body — either ties up a worker per connection or buffers the stream until it ends. This guide sets up the working configuration, handles client disconnects, and deals with the ORM from async code.

Symptom & Developer Intent Permalink to this section

  • Each open stream occupies a Gunicorn sync worker, and the site stops responding after a few dozen viewers.
  • Events arrive in one burst when the client disconnects.
  • Django logs a warning that a StreamingHttpResponse must consume a synchronous iterator in order to serve it asynchronously, or vice versa.
  • Generators keep polling the database after users close the tab.
  • SynchronousOnlyOperation errors appear when the stream queries models.

The intent is a Django endpoint that holds thousands of idle streams on a few processes, delivers each event immediately, stops when the client leaves and reads the database safely.

Root Cause Analysis Permalink to this section

Under WSGI, every request is handled by a worker thread or process until the response is complete. A stream is never complete, so each viewer consumes a worker permanently. Under ASGI with an async view, an idle stream is a suspended coroutine, costing kilobytes rather than a thread.

Django serving modes for a streaming response Matrix comparing WSGI with a sync generator, ASGI with a sync generator, and ASGI with an async generator on concurrency cost, disconnect detection and suitability for SSE. Django serving modes for a streaming response Mode Cost per open stream Disconnect detection For SSE WSGI + sync generator one worker on write failure no ASGI + sync generator one thread delayed small scale ASGI + async generator a coroutine CancelledError yes suitable workable unsuitable
Only the bottom row scales. The middle row works but runs the generator in a thread per stream, which Django warns about.

Bursting comes from middleware. GZipMiddleware compresses streaming responses by wrapping the iterator, and a compressor emits bytes only when its buffer fills. Other middleware that reads response.content, or debugging tools that capture the body, force the whole stream to be consumed first.

SynchronousOnlyOperation is Django protecting you from calling the synchronous ORM on the event loop. Async ORM methods (aget, afilter iteration, acount) or sync_to_async are required inside the generator.

Step-by-Step Resolution Permalink to this section

Step 1 — Run Django under an ASGI server Permalink to this section

pip install "uvicorn[standard]"   # or daphne, hypercorn, or gunicorn with uvicorn workers
uvicorn mysite.asgi:application --host 0.0.0.0 --port 8000 --workers 4

Step 2 — Write an async view that returns an async generator Permalink to this section

# events/views.py
import asyncio, json
from django.http import StreamingHttpResponse
from django.views.decorators.http import require_GET

@require_GET
async def event_stream(request):
    user = await request.auser()                     # async auth (Django 5.0+)
    if not user.is_authenticated:
        from django.http import HttpResponse
        return HttpResponse(status=401)              # EventSource stops retrying on 401
    last_id = int(request.headers.get("Last-Event-ID", 0) or 0)

    async def stream():
        yield "retry: 5000\n\n"
        async for n in Notification.objects.filter(user=user, id__gt=last_id).order_by("id")[:500]:
            yield frame(n)                           # async ORM iteration
        queue = await hub.subscribe(user.pk)
        try:
            while True:
                try:
                    n = await asyncio.wait_for(queue.get(), timeout=15)
                    yield frame(n)
                except asyncio.TimeoutError:
                    yield ": hb\n\n"                 # heartbeat, and a write that detects dead peers
        except asyncio.CancelledError:
            raise                                    # client disconnected: let Django unwind
        finally:
            await hub.unsubscribe(user.pk, queue)

    response = StreamingHttpResponse(stream(), content_type="text/event-stream")
    response["Cache-Control"] = "no-cache"
    response["X-Accel-Buffering"] = "no"
    return response

def frame(n):
    return f"id: {n.id}\nevent: notification\ndata: {json.dumps({'id': n.id, 'text': n.text})}\n\n"

Since Django 5.0, a client disconnect during an async streaming response cancels the generator with asyncio.CancelledError, so the finally block runs promptly. On older versions, the disconnect is discovered only when a write fails — the heartbeat makes that happen within seconds.

Disconnect handling in an async Django stream Sequence diagram of a browser closing its connection, the ASGI server sending http.disconnect, Django cancelling the generator, and the finally block unsubscribing from the hub. Disconnect handling in an async Django stream Browser ASGI server Django Hub connection closed http.disconnect cancel generator (CancelledError) finally: unsubscribe
With async views the disconnect becomes a cancellation of your generator. Put every release in finally so it runs on this path.

Step 3 — Keep buffering middleware away from the stream Permalink to this section

# settings.py — remove GZip for the stream, or exclude it by path in a small wrapper.
MIDDLEWARE = [m for m in MIDDLEWARE if m != "django.middleware.gzip.GZipMiddleware"]

If you need compression elsewhere, write a tiny middleware that skips responses whose content type is text/event-stream, and put it in place of GZipMiddleware. Remove debug toolbars from stream URLs too.

Step 4 — Feed the hub from a broker Permalink to this section

Each ASGI worker process has its own hub. Feed it from Redis pub/sub, or use Django Channels’ channel layer if the project already has one, so events published by any process (a Celery task, a model signal in another worker) reach every stream:

# events/hub.py — one Redis subscription per process, fan-out to local queues.
import asyncio, json, redis.asyncio as redis

class Hub:
    def __init__(self):
        self.queues: dict[int, set[asyncio.Queue]] = {}
        self._task = None

    async def subscribe(self, user_id):
        if self._task is None:
            self._task = asyncio.create_task(self._pump())
        q = asyncio.Queue(maxsize=256)
        self.queues.setdefault(user_id, set()).add(q)
        return q

    async def unsubscribe(self, user_id, q):
        self.queues.get(user_id, set()).discard(q)

    async def _pump(self):
        ps = redis.from_url(REDIS_URL).pubsub()
        await ps.psubscribe("notify:*")
        async for m in ps.listen():
            if m["type"] != "pmessage":
                continue
            uid = int(m["channel"].decode().split(":")[1])
            for q in list(self.queues.get(uid, ())):
                if not q.full():
                    q.put_nowait(Notification(**json.loads(m["data"])))

hub = Hub()

Step 5 — Route streams separately if the rest of the site stays on WSGI Permalink to this section

Many Django projects cannot move wholesale to ASGI overnight. A common transitional setup serves ordinary pages from the existing WSGI deployment and routes only the stream URL prefix to an ASGI deployment of the same codebase, at the proxy. Both deployments share settings, models and the database; the ASGI one only needs to serve /events/. This isolates long-lived connections from the request workers entirely, which is worth keeping even after a full migration: a traffic spike on the live page can then never starve form submissions and page loads of workers.

Validation & Monitoring Permalink to this section

# Streams must be unbuffered and survive idle periods.
curl -sN -b sessionid=$SID http://localhost:8000/events/stream | while IFS= read -r l; do echo "$(date +%T) $l"; done

# Concurrency: 2,000 idle streams on one uvicorn worker should use little memory.
for i in $(seq 1 2000); do curl -sN -b sessionid=$SID http://localhost:8000/events/stream > /dev/null & done
ps -o rss= -p $(pgrep -f 'uvicorn mysite.asgi' | head -1)
Idle streams one host can hold Bar chart comparing the number of concurrent idle SSE streams sustainable on a four-core host with Gunicorn sync workers, ASGI with a sync generator, and ASGI with an async generator. Idle streams one host can hold WSGI, 9 sync workers 9 ASGI, sync generator ~160 (thread pool) ASGI, async generator ~20,000 concurrent idle streams on a 4-core, 8 GB host
Under WSGI the worker count is the stream count. Async views move the limit to memory and file descriptors.

Log stream open and close with duration and the close cause (cancelled versus error). Expose the hub’s queue count per process as a metric; it should equal open streams.

Production Checklist Permalink to this section

Frequently Asked Questions Permalink to this section

Can I serve SSE from Django under WSGI?

Only at very small scale, because each stream holds a worker for its whole life. Use ASGI for anything beyond a handful of concurrent viewers, or route stream URLs to a separate ASGI deployment of the same project.

Do I need Django Channels?

No. Async views with StreamingHttpResponse are enough for SSE. Channels is useful if you already use its channel layer as the broker, or need WebSockets as well.

Why do I get SynchronousOnlyOperation inside the generator?

The generator runs on the event loop, where the synchronous ORM is not allowed. Use the async query methods, or wrap a synchronous call with sync_to_async.

How do I detect disconnects on Django versions before 5.0?

Rely on writes failing: send heartbeats and handle the exception from the write path. Django 5.0 and later cancel the generator on disconnect, which is faster and cleaner.