Django Advanced #6: Django Channels — WebSocket

8 min read

This is the peak of the async stack introduced in #1 ASGI — the place to build real-time bidirectional communication using WebSocket. The tool: Django Channels.

Why WebSocket? #

Use cases that the HTTP request-response model can’t handle well:

  • Real-time notifications — instantly when a new message arrives
  • Chat, collaborative editing — bidirectional, low latency
  • Live dashboards — server pushes updates
  • Games, whiteboards — frequent bidirectional traffic

Candidate solutions:

PollingLong pollingSSEWebSocket
DirectionClient → server (interval)Client → server (wait)Server → client one-wayBidirectional
Connection lifetimeShortLong requestLong responseAlways open
Proxy/firewallFriendlyFriendlyFriendly (HTTP)Needs upgrade
Implementation difficultyVery easyEasyEasyMedium
CaseSimple updatesSome notificationsOne-way pushBidirectional, chat

For server → client one-way, SSE (Server-Sent Events) is also a fine choice. WebSocket when bidirectional is required.

Where Channels fits #

Django itself supports async views on ASGI, but it doesn’t handle the WebSocket protocol. Channels fills that gap.

What Channels brings:

  • Handling WebSocket / other protocols
  • Channel Layer — message exchange between workers (typically Redis backend)
  • Auth/session middleware (same tools as HTTP)
  • ASGI routing

Install and setup #

Install
pip install channels channels-redis
settings.py
INSTALLED_APPS = [
    ...,
    "daphne",        # ASGI server, integrates with runserver
    "channels",
    "myapp",
]

ASGI_APPLICATION = "myproject.asgi.application"

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    },
}

If you place daphne near the top of INSTALLED_APPS, runserver runs on daphne — so WebSocket works in dev too.

myproject/asgi.py
import os
import django

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django.setup()

from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from django.core.asgi import get_asgi_application
from myapp.routing import websocket_urlpatterns

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": AuthMiddlewareStack(
        URLRouter(websocket_urlpatterns)
    ),
})

Key parts:

  • ProtocolTypeRouter — routes per http, websocket
  • HTTP keeps its existing Django flow
  • WebSocket has its own router
  • AuthMiddlewareStack — picks up the user from the session cookie

Your first Consumer #

A Consumer is the WebSocket version of a view.

myapp/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class EchoConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()
        await self.send(text_data=json.dumps({"hello": "world"}))

    async def disconnect(self, close_code):
        pass

    async def receive(self, text_data=None, bytes_data=None):
        data = json.loads(text_data)
        await self.send(text_data=json.dumps({"echo": data}))

Three hooks:

  • connect — client tries to connect. accept() to allow; if not called, refuses
  • disconnect — disconnection
  • receive — message received
myapp/routing.py
from django.urls import re_path
from . import consumers

websocket_urlpatterns = [
    re_path(r"ws/echo/$", consumers.EchoConsumer.as_asgi()),
]

as_asgi() is the WebSocket version of as_view(). It turns the class instance into an ASGI app.

Client side #

In the browser
<script>
const ws = new WebSocket("ws://localhost:8000/ws/echo/");
ws.onopen = () => ws.send(JSON.stringify({msg: "hi"}));
ws.onmessage = (e) => console.log(JSON.parse(e.data));
</script>

Group — broadcast #

Here’s where the real value lies: sending the same message to many connections at once.

Chat room Consumer
import json
from channels.generic.websocket import AsyncWebsocketConsumer

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"]["room"]
        self.group_name = f"chat_{self.room_name}"

        # Join the group
        await self.channel_layer.group_add(self.group_name, self.channel_name)
        await self.accept()

    async def disconnect(self, close_code):
        await self.channel_layer.group_discard(self.group_name, self.channel_name)

    async def receive(self, text_data=None, bytes_data=None):
        data = json.loads(text_data)
        message = data["message"]
        user = self.scope["user"]
        username = user.username if user.is_authenticated else "anon"

        # Send to the whole group
        await self.channel_layer.group_send(
            self.group_name,
            {
                "type": "chat.message",   # matches the handler name
                "username": username,
                "message": message,
            },
        )

    async def chat_message(self, event):
        # group_send's type="chat.message" calls this
        await self.send(text_data=json.dumps({
            "username": event["username"],
            "message": event["message"],
        }))
routing.py
websocket_urlpatterns = [
    re_path(r"ws/chat/(?P<room>\w+)/$", consumers.ChatConsumer.as_asgi()),
]

Key mechanism:

  • channel_name — unique ID for this connection (Channels auto-assigns)
  • group_name — the logical channel we name (e.g., chat_general)
  • group_add — join this connection to the group
  • group_send — message all connections in the group
  • The message’s type field (chat.message) → calls the matching method (chat_message) on the same Consumer

The dot (.) in the type converts to an underscore (_) in the method name. Naming convention.

What the Channel Layer does #

channel_layer.group_send runs on top of Redis lists / pub-sub. The message:

  1. Pushes into Redis
  2. Every channel in the group picks it up
  3. Each one calls the handler on its own Consumer

This is the path for sending the same message to connections scattered across many workers/servers. In a single process you can use InMemoryChannelLayer, but production = Redis.

Push from an HTTP view #

It’s not a WebSocket-only world. WebSocket really pays off when regular HTTP views, signals, or Celery tasks can also push messages to open WebSocket connections.

views.py — push notification
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer

def create_notification(request):
    notif = Notification.objects.create(
        user=request.user,
        text=request.POST["text"],
    )

    channel_layer = get_channel_layer()
    async_to_sync(channel_layer.group_send)(
        f"user_{request.user.id}",
        {
            "type": "notify.message",
            "id": notif.id,
            "text": notif.text,
        },
    )
    return JsonResponse({"ok": True})

Get the layer with get_channel_layer(), and wrap with async_to_sync in a sync view (#1 adapter).

In an async view / Celery (when async is supported), just await.

Pairing with Celery / signals #

The transaction.on_commit pattern from #5 carries over directly.

Signal → WebSocket
@receiver(post_save, sender=Notification)
def push_notification(sender, instance, created, **kwargs):
    if not created:
        return

    def push():
        channel_layer = get_channel_layer()
        async_to_sync(channel_layer.group_send)(
            f"user_{instance.user_id}",
            {"type": "notify.message", "id": instance.id, "text": instance.text},
        )

    transaction.on_commit(push)

Push after the transaction commits — no push for a notification that doesn’t exist.

Auth — AuthMiddlewareStack #

The one you saw in asgi.py.

Automatic
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),

It reads the session cookie and fills scope["user"]. Inside a Consumer:

Auth check
async def connect(self):
    user = self.scope["user"]
    if not user.is_authenticated:
        await self.close()
        return
    await self.accept()

Token auth (JWT etc.) #

When you use tokens instead of session cookies, write a custom middleware.

myapp/middleware.py
from urllib.parse import parse_qs
from channels.db import database_sync_to_async
from django.contrib.auth.models import AnonymousUser

class TokenAuthMiddleware:
    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        query = parse_qs(scope.get("query_string", b"").decode())
        token = query.get("token", [None])[0]
        scope["user"] = await self.authenticate(token)
        return await self.app(scope, receive, send)

    @database_sync_to_async
    def authenticate(self, token):
        from .models import AuthToken
        if not token:
            return AnonymousUser()
        try:
            return AuthToken.objects.select_related("user").get(value=token).user
        except AuthToken.DoesNotExist:
            return AnonymousUser()

database_sync_to_async brings ORM calls safely into async context.

asgi.py — applied
"websocket": TokenAuthMiddleware(URLRouter(websocket_urlpatterns)),

Deployment #

daphne or uvicorn #

The ASGI server seen in #1. Since it also handles WebSocket, choose between daphne / uvicorn / hypercorn.

daphne
daphne -b 0.0.0.0 -p 8000 myproject.asgi:application
gunicorn + uvicorn worker
gunicorn myproject.asgi:application \
  -k uvicorn.workers.UvicornWorker \
  --workers 4 --bind 0.0.0.0:8000

nginx WebSocket proxy #

If your reverse proxy is nginx, you must declare WebSocket upgrade explicitly.

nginx.conf
upstream django_asgi {
    server 127.0.0.1:8000;
}

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name myapp.com;

    location / {
        proxy_pass http://django_asgi;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket upgrade
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;

        # Timeout — keep WebSocket long
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

Key parts:

  • proxy_http_version 1.1
  • Forward Upgrade, Connection headers
  • Make proxy_read_timeout long (default 60s drops you in a minute)

Worker count #

WebSocket workers handle long-lived connections, so unlike sync workers, “one request = one worker occupied” doesn’t apply. A single worker can handle thousands of connections. The bottleneck is memory and connection limits, not CPU.

Split Channels for simple sites #

Serving HTTP and WebSocket from the same ASGI app is simplest. As traffic grows:

  • HTTP via gunicorn + sync worker (fast, well-known operations)
  • WebSocket only via a separate daphne process

This split pattern is common. nginx routes by path (/ws/* → daphne, the rest → gunicorn).

Common pitfalls #

1) channel_layer.group_send doesn’t deliver #

CHANNEL_LAYERS not configured or Redis connection failure. If get_channel_layer() returns None, the layer is unset.

2) Doesn’t reach connections in another worker #

InMemoryChannelLayer only shares within the same process. Multiple workers must use Redis (or another external) backend.

3) SynchronousOnlyOperation on ORM calls #

Sync ORM call inside an async Consumer. Use the a method or database_sync_to_async.

from channels.db import database_sync_to_async

@database_sync_to_async
def get_recent_messages(room):
    return list(Message.objects.filter(room=room)[:50])

async def connect(self):
    msgs = await get_recent_messages(self.room_name)

4) Message type and method name #

type="chat.message"async def chat_message(self, event). Dot becomes underscore. If they don’t match, the message is silently ignored (no error either).

5) Ignoring close code #

In disconnect(close_code), the close_code distinguishes normal close / error / expiration. Use it for logging.

6) Tracking auth changes #

AuthMiddlewareStack uses the user info from connection time. Even if the session expires or the password changes, the connection stays alive. For sensitive cases, re-authenticate periodically.

Alternatives — quickly #

Outside the Django camp:

Best fit
SSEServer → client one-way, HTTP-only is enough
Pusher / AblyManaged — when you don’t want to run infra
Phoenix Channels (Elixir)Tens of thousands of connections
Socket.io (Node)Auto fallback, friendly to JS ecosystem
CentrifugoSeparate process for messaging, language-agnostic

Django + Channels is a good fit when you already have a Django site and want to add real-time features. If your goal from day one is hundreds of thousands of concurrent connections, it’s worth honestly considering another stack.

Wrap-up #

What you covered this time:

  • WebSocket’s use case (bidirectional, low latency); SSE is also fine for one-way
  • Channels = WebSocket/ASGI camp on top of Django; channel layer (Redis) is core
  • Split http / websocket with ProtocolTypeRouter, AuthMiddlewareStack
  • AsyncWebsocketConsumerconnect/disconnect/receive
  • Group: group_add, group_send, group_discard, type/method mapping
  • Push from HTTP view/signal/Celery: get_channel_layer + async_to_sync(group_send)
  • Safe push via signal + transaction.on_commit
  • Auth: sessions via AuthMiddlewareStack, tokens via custom middleware
  • Deployment: daphne / uvicorn, nginx Upgrade/Connection, read_timeout
  • Pitfalls: SynchronousOnlyOperation, type naming, layer unset, multiple workers
  • Alternatives: SSE, Pusher/Ably, Centrifugo

In the next post (#7 Deployment security) we wrap up the series with the final piece of operations — settings split, ALLOWED_HOSTS, CSRF, cookie security, secret management, manage.py check –deploy.

X