Django Advanced #6: Django Channels — WebSocket
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:
| Polling | Long polling | SSE | WebSocket | |
|---|---|---|---|---|
| Direction | Client → server (interval) | Client → server (wait) | Server → client one-way | Bidirectional |
| Connection lifetime | Short | Long request | Long response | Always open |
| Proxy/firewall | Friendly | Friendly | Friendly (HTTP) | Needs upgrade |
| Implementation difficulty | Very easy | Easy | Easy | Medium |
| Case | Simple updates | Some notifications | One-way push | Bidirectional, 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 #
pip install channels channels-redisINSTALLED_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.
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 perhttp,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.
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, refusesdisconnect— disconnectionreceive— message received
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 #
<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.
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"],
}))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 groupgroup_send— message all connections in the group- The message’s
typefield (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:
- Pushes into Redis
- Every channel in the group picks it up
- 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.
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.
@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.
"websocket": AuthMiddlewareStack(URLRouter(websocket_urlpatterns)),It reads the session cookie and fills scope["user"]. Inside a Consumer:
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.
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.
"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 -b 0.0.0.0 -p 8000 myproject.asgi:applicationgunicorn myproject.asgi:application \
-k uvicorn.workers.UvicornWorker \
--workers 4 --bind 0.0.0.0:8000nginx WebSocket proxy #
If your reverse proxy is nginx, you must declare WebSocket upgrade explicitly.
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,Connectionheaders - Make
proxy_read_timeoutlong (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 | |
|---|---|
| SSE | Server → client one-way, HTTP-only is enough |
| Pusher / Ably | Managed — 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 |
| Centrifugo | Separate 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 AsyncWebsocketConsumer—connect/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.