Skip to content

How Django handles long-lived connections: WebSockets, Server-Sent Events and live notifications. The core idea is knowing when code runs sync versus async, and what serves each. Deployment context: Django on ECS Fargate - Architecture and Lessons.

WSGI vs ASGI

WSGI ASGI
Model Synchronous, one request per worker at a time Asynchronous, many concurrent connections
Connections Short request / response Also WebSockets, SSE, long polling
Servers Gunicorn, uWSGI Daphne, Uvicorn, Hypercorn
Entry point wsgi.py (application) asgi.py (application)
Use when Ordinary pages and APIs Real-time features

manage.py runserver is a development server only. In production use Gunicorn (WSGI) or an ASGI server, behind a load balancer. Gunicorn can also host ASGI apps with Uvicorn workers.

$ gunicorn config.wsgi:application --workers 3 --timeout 60
$ daphne -b 0.0.0.0 -p 8000 config.asgi:application
$ uvicorn config.asgi:application --host 0.0.0.0 --port 8000

Sync vs Async: the Conceptual Gap

Async Django still has a synchronous core (the ORM, most libraries). Mixing them wrongly causes deadlocks, blocked event loops and dropped connections.

Situation Rule
Calling sync code (ORM, requests) from async def Wrap with sync_to_async or use async APIs (await Model.objects.aget(...), async for obj in qs)
Calling async code from a normal view async_to_sync
Slow blocking call inside async code Blocks every connection on that worker; move it to a thread or a Celery task
ORM in async context without wrapping SynchronousOnlyOperation error
from asgiref.sync import sync_to_async

@sync_to_async
def get_unread(user_id):
    return list(Notification.objects.filter(user_id=user_id, read=False))

async def unread_view(request):
    items = await get_unread(request.user.id)
    return JsonResponse({"count": len(items)})

Django Channels

Channels adds WebSockets (and other protocols) to Django on top of ASGI.

Piece Role
Consumer Like a view for a connection: connect, receive, disconnect
Routing Maps WebSocket URLs to consumers (URLRouter)
Channel layer Message passing between consumers and from the rest of Django; Redis (channels_redis) in production
Group Named set of channels, e.g. case_42, to broadcast to many clients
class NotifyConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        self.group = f"user_{self.scope['user'].id}"
        await self.channel_layer.group_add(self.group, self.channel_name)
        await self.accept()

    async def notify(self, event):                 # handler for type="notify"
        await self.send_json(event["payload"])

# from anywhere (e.g. a signal, view or Celery task):
async_to_sync(channel_layer.group_send)(
    "user_7", {"type": "notify", "payload": {"text": "Case updated"}})

Redis plays two roles (keep them isolated)

Role Purpose
Channel layer Message passing between consumers
Cache backend Ordinary caching

Configure them on different Redis databases or key prefixes so they never collide.


Server-Sent Events (SSE)

For one-way server-to-browser streams (notifications, live chat messages), SSE is simpler than WebSockets: plain HTTP, automatic reconnect in the browser, works through most proxies.

sequenceDiagram
    participant U as Browser (EventSource)
    participant S as Async Django view (Daphne)
    participant R as Redis pub/sub
    participant W as Writer (view / signal)
    U->>S: GET /events (Accept: text/event-stream)
    S->>R: Subscribe to channel
    W->>W: Save message to database
    W->>R: Publish message
    R-->>S: Message
    S-->>U: id: ...  data: {...}
from django.http import StreamingHttpResponse

async def stream(request, case_id):
    last_id = request.headers.get("Last-Event-ID")

    async def events():
        async for msg in subscribe(f"case:{case_id}", after=last_id):   # Redis pub/sub
            yield f"id: {msg['id']}\ndata: {json.dumps(msg)}\n\n"

    resp = StreamingHttpResponse(events(), content_type="text/event-stream")
    resp["Cache-Control"] = "no-cache"
    resp["X-Accel-Buffering"] = "no"           # stop proxy buffering
    return resp

Design points: - Decouple persistence from streaming: the message is saved first, a signal publishes it to Redis, and the SSE view only streams - Reconnect recovery: browsers send Last-Event-ID on reconnect; use an ID such as an ISO 8601 timestamp plus UUID to resume without duplicates or gaps - Feature flag (for example CHAT_REALTIME_ENABLED) so real-time can be switched off without redeploying; the UI falls back to polling - Heartbeat comments (: ping) keep idle connections open through load balancers

Infrastructure implications

  • ALB idle timeout (default 60 s) closes quiet streams: raise it or send heartbeats (AWS Elastic Load Balancing Deep Dive)
  • CloudFront and proxies must not buffer or cache the stream; use a behavior with caching disabled and a long origin timeout (AWS CloudFront Deep Dive)
  • Long connections hold a worker/coroutine each: size workers and use an ASGI server for these paths

Choosing

Need Pick
Normal pages / REST WSGI + Gunicorn
Server to browser stream only SSE over async views
Two-way real time (chat, collaboration, presence) WebSockets with Channels
Occasional updates, simple Polling, optionally short-lived
Heavy work triggered by a request Celery task; push status over SSE / WebSocket (Celery and Message Brokers)

Checklist

  • [ ] Know which code is sync and which async; no raw ORM in async paths
  • [ ] Production server chosen (Gunicorn or an ASGI server), not runserver
  • [ ] Redis channel layer and cache isolated
  • [ ] Reconnect handled with Last-Event-ID; flag to disable
  • [ ] Load balancer and CDN timeouts and buffering configured