Real-Time Features with WebSockets and Django Channels
A practical guide to adding WebSocket support to Django, handling connection state, and keeping things performant at scale.
Django is a synchronous framework by design, and that’s mostly fine. HTTP is request-response — you get a request, you return a response, the connection closes. But modern applications need something different: live dashboards, collaborative features, instant notifications. For that, you need a persistent connection.
Django Channels extends Django to handle WebSockets, long-polling, and other async protocols. It’s the official solution, well-maintained, and production-proven. Here’s how to use it without shooting yourself in the foot.
Architecture Overview
Without Channels, Django runs through a single WSGI process per request. With Channels, you add:
- ASGI interface — replaces WSGI, handles both HTTP and WebSocket connections
- Channel layer — a message-passing backend (Redis) that lets different server processes communicate
- Consumers — async Python classes that handle WebSocket connections, analogous to Django views
The mental model: a consumer is a long-lived object. It connects, handles messages, and eventually disconnects. Unlike views, it has state throughout the connection.
Setup
Install the dependencies:
pip install channels channels-redis daphne
Update settings.py:
INSTALLED_APPS = [
# ...
'channels',
'daphne',
]
ASGI_APPLICATION = 'myproject.asgi.application'
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [('redis', 6379)],
},
},
}
Update asgi.py:
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
import myapp.routing
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(myapp.routing.websocket_urlpatterns)
),
})
Writing a Consumer
Here’s a consumer for a real-time notification feed. When a user connects, they join a group specific to their user ID. When you want to push a notification, you send a message to that group:
# consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class NotificationConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.user = self.scope['user']
if not self.user.is_authenticated:
await self.close()
return
self.group_name = f'notifications_{self.user.id}'
# Join the user's notification 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):
# Handle messages from the client
data = json.loads(text_data)
if data.get('type') == 'ping':
await self.send(text_data=json.dumps({'type': 'pong'}))
# Called when a message is sent to this user's group
async def notification_message(self, event):
await self.send(text_data=json.dumps({
'type': 'notification',
'message': event['message'],
'data': event.get('data', {}),
}))
# routing.py
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/notifications/$', consumers.NotificationConsumer.as_asgi()),
]
Sending Messages from Django Code
The beauty of the channel layer is that you can push to a WebSocket from anywhere in your Django codebase — a Celery task, a Django signal, a management command:
# tasks.py
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
def notify_user(user_id: int, message: str, data: dict = None):
async_to_sync(channel_layer.group_send)(
f'notifications_{user_id}',
{
'type': 'notification_message', # maps to the consumer method
'message': message,
'data': data or {},
}
)
Call notify_user(user.id, "Your export is ready", {"download_url": url}) from any synchronous Django code, and all browser tabs connected as that user receive the message instantly.
Connection State and Reconnection
The biggest mistake I see in WebSocket implementations is treating the connection as reliable. Networks drop, servers restart, browsers go to sleep. You need a reconnection strategy on the client:
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.reconnectDelay = 1000;
this.maxDelay = 30000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectDelay = 1000; // reset on successful connection
};
this.ws.onmessage = (event) => {
this.handleMessage(JSON.parse(event.data));
};
this.ws.onclose = (event) => {
if (event.code !== 1000) { // 1000 = normal closure
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(
this.reconnectDelay * 2,
this.maxDelay
);
}
};
}
handleMessage(data) {
// handle incoming messages
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
Exponential backoff prevents hammering your server when it restarts.
Scaling: One Redis, Many Processes
The channel layer handles scaling. When you run multiple Daphne processes (or Uvicorn workers) behind a load balancer, each process has its own WebSocket connections but they all share the same Redis channel layer.
When you call channel_layer.group_send, Redis broadcasts to all processes in the group. Each process checks if any of its local connections belong to that group and delivers the message. This works transparently across 1 server or 20.
upstream websocket {
server app1:8000;
server app2:8000;
server app3:8000;
}
server {
location /ws/ {
proxy_pass http://websocket;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400; # 24h - prevents nginx from closing idle connections
}
}
A Note on Performance
Channels is not the right tool for high-frequency, low-latency data streaming (think: live trading feeds, multiplayer games). For 99% of web application use cases — notifications, chat, live dashboards, collaborative editing — it performs excellently.
For the government education platform I worked on, we used Channels to push real-time exam status updates to thousands of simultaneous students. It held up without issue on a single Redis instance and two Daphne processes.
If you’re pushing more than a few thousand messages per second, benchmark your Redis instance. That’s typically where the limit is, not Django itself.
Working on something similar?
Let's talk about your project.
If you are facing a similar challenge, we can make the scope, risks, and technical next step concrete.
Discuss your project