Django Channels
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page builds two real-time servers on Django Channels: the Chatterbox chat server, which speaks the shared JSON wire
protocol defined in Message Design and Serialization,
and the authoritative game loop of Pixel Arena. It does not explain Channels itself. Consumers, ProtocolTypeRouter,
URLRouter, AuthMiddlewareStack, origin validators, the channel-layer API, Daphne’s options and the Channels 2 to 4
migration table are in the reference page Channels & WebSockets; read it
first, and use this page for the patterns that sit on top of it.
The baseline is Django 6.1.2 (released 2026-10-06), Python 3.13 (the python:3.13.17-slim-trixie image), Channels
4.3.2 (2025-11-20), Daphne 4.2.3 (2026-07-21), channels_redis 4.3.0 (2025-07-22), redis-py 7.4.1, Uvicorn 0.54.0 and
Redis server 8.8.3 (the 8.8.3-alpine image), all checked on 2026-10-24. Channels 4.3.2 declares Django 6.0 as its
newest classifier; it ran unchanged under Django 6.1.2. Every listing below was run with Docker Compose: a Node 24.21
conformance client (the global WebSocket, no library) passed all 36 checks against Daphne and again against Uvicorn,
and three WebsocketCommunicator tests passed.
Who uses what. The chat scenario uses everything on this page: the consumer, history in the database, presence, typing,
receipts and resume. The game scenario uses the worker and the second, short-lived channel layer. The videoconference
scenario (Huddle) needs only a signalling relay, which is the same consumer pattern with offer, answer and candidate
payloads instead of chat messages (see
Signaling and Perfect Negotiation).
What this page adds
| Need | Where it is solved |
|---|---|
One consumer for all twelve Chatterbox message types |
The Chatterbox consumer: a single |
Acknowledgements that survive retries |
The ORM half: transactions, sequence numbers, history: the ack row is written in the same transaction as the change, so a resent id returns the original ack with |
Ordering and history |
The ORM half: transactions, sequence numbers, history: a per-room counter updated before it is read, history paging on |
Who is online |
Presence with expiry in Redis: Redis sorted sets whose entries and keys expire by themselves, plus a 15-second grace period. |
Typing, receipts, resume |
Typing, receipts and resume: a Redis |
A game loop that is not the web process |
The Pixel Arena game loop: a |
Capacity and expiry |
Settings: two channel layers: two channel layers with different |
Daphne or Uvicorn, and tests |
Architecture
Two processes and two Redis uses. The web process (Daphne or Uvicorn) holds the WebSockets and runs one
ChatConsumer per connection. The worker process owns the game world. Redis carries three things: the chat channel
layer, the game channel layer, and the presence and ticket keys. The database holds what must not be lost: rooms,
memberships, events and the idempotency table.
The numbers 1 and 2 on the first arrows are the order that matters: the database commit comes before the broadcast, so the layer never announces a message that does not exist. The reverse failure, a commit followed by a lost broadcast, is handled by sequence numbers (Common errors and pitfalls).
Dependencies, container image and Compose file
Pin every package. The pins below are the exact set that ran.
Django==6.1.2
channels==4.3.2
daphne==4.2.3
channels-redis==4.3.0
redis==7.4.1
The redis==7.4.1 line is deliberate. channels_redis 4.3.0 declares only redis>=4.6, so an unpinned install takes
redis-py 8.1.0 today, and redis-py 8.0.0 changed the defaults: socket_timeout and socket_connect_timeout are now 5
seconds, according to its release notes, which also warn that a socket timeout can break blocking commands such as
BLPOP and BRPOP. channels_redis waits for messages with exactly such commands. With redis-py 8.1.0 the
runworker process of this page crashed at start-up with a TimeoutError ("Timeout reading from redis:6379"), and a standalone BZPOPMIN with a 10-second timeout failed after 5.0 seconds; with 7.4.1 the same probe
waited the full 10 seconds and the worker ran. Keep redis below 8 until channels_redis documents support for it.
FROM python:3.13.17-slim-trixie
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "config.asgi:application"]
# docker compose -p chatterbox up --build
services:
redis:
image: redis:8.8.3-alpine
ports:
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 2s
retries: 15
web:
build: .
command: sh -c "python manage.py migrate --noinput && daphne -b 0.0.0.0 -p 8000 --websocket-max-message-size 65536 config.asgi:application"
environment:
REDIS_URL: redis://redis:6379/0
DJANGO_SECRET_KEY: change-me
ALLOWED_HOSTS: localhost,127.0.0.1
CHATTERBOX_ORIGINS: http://localhost:8000
SQLITE_PATH: /data/db.sqlite3
volumes:
- data:/data
ports:
- "127.0.0.1:${WEB_PORT:-8000}:8000"
depends_on:
redis:
condition: service_healthy
worker:
build: .
command: python manage.py runworker --layer game arena
environment:
REDIS_URL: redis://redis:6379/0
DJANGO_SECRET_KEY: change-me
SQLITE_PATH: /data/db.sqlite3
volumes:
- data:/data
depends_on:
redis:
condition: service_healthy
volumes:
data:
The compose file starts three services: Redis with a health check, the web process (it runs the migrations and then
Daphne) and the worker. The web port and the Redis port are overridable with WEB_PORT and REDIS_PORT, and both bind to
127.0.0.1 so that nothing is exposed by accident. Secrets are placeholders (change-me); a real deployment reads
them from the environment or a secret store. --websocket-max-message-size 65536 makes Daphne refuse oversized messages
before they reach Python; the application enforces the protocol’s own 16384-byte limit afterwards, so the client gets a
proper too_large error and close code 4413.
Source: Compose file reference, python image and redis image
The project layout:
chatterbox/
compose.yaml Dockerfile requirements.txt manage.py
config/ settings.py asgi.py urls.py
chat/
models.py # Room, Membership, Event, Processed
protocol.py # envelope helpers, ChatError, validation
store.py # synchronous ORM half (run in threads)
presence.py # Redis: presence, tickets, typing throttle, sessions
redis_client.py # one redis-py client per event loop
consumers.py # ChatConsumer
arena.py # ArenaWorker and ArenaConsumer
security.py # OptionalOriginValidator
routing.py views.py management/commands/mint_ticket.py
tests/test_chat.py
Settings: two channel layers
One layer is rarely enough. Chat events are worth keeping for a while; game snapshots are worth nothing a second after
they were sent. Channels lets a consumer pick a layer by alias (channel_layer_alias), so the two kinds of traffic get
different limits and cannot starve each other.
# config/settings.py (excerpt)
CHATTERBOX_ORIGINS = os.environ.get("CHATTERBOX_ORIGINS", "http://localhost:8000").split(",")
REDIS_URL = os.environ.get("REDIS_URL")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": os.environ.get("SQLITE_PATH", BASE_DIR / "db.sqlite3"),
# BEGIN IMMEDIATE: take the write lock at the start of atomic(), so the
# per-room seq counter cannot be read stale (Django 5.1+).
"OPTIONS": {"transaction_mode": "IMMEDIATE", "timeout": 20},
}
}
if REDIS_URL:
CHANNEL_LAYERS = {
# chat: durable-ish events, a minute of grace for slow consumers
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [REDIS_URL],
"prefix": "chatterbox",
"expiry": 60,
"capacity": 200,
},
},
# game: snapshots are worthless after a second, so expire fast and
# keep the queues short; the worker's own queue gets more room
"game": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [REDIS_URL],
"prefix": "arena",
"expiry": 1,
"capacity": 10,
"channel_capacity": {"arena": 500},
},
},
}
else:
CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
"game": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
Source: Channel layers: configuration, channels_redis README and Django: SQLite transaction behavior
What each knob does, and why the two layers differ:
| Option | Default | Chat layer | Game layer | Reasoning |
|---|---|---|---|---|
|
60 s |
60 |
1 |
Undelivered messages are dropped after this many seconds. A chat event may wait for a slow consumer; a snapshot older than a second is already wrong. |
|
100 |
200 |
10 |
Messages one channel can hold. When it is full, |
|
none |
none |
|
Per-channel override by name (glob or compiled regular expression). The worker’s named channel receives the input of every player, so it needs more room than a player’s own channel. |
|
86400 s |
default |
default |
How long a channel stays in a group after |
|
|
|
|
Redis key prefix. Layers that share a Redis server need distinct prefixes. |
Three further points. First, InMemoryChannelLayer is for development and tests only: each process has its own, so two
server processes never see each other’s rooms. Since Channels 4.2 it honours expiry and per-channel capacities, which
makes tests of the limits possible. Second, RedisPubSubChannelLayer (also in channels_redis) is labelled Beta in its
README and can drop messages during a network partition; it offers on_disconnect and on_reconnect hooks to re-sync.
The page uses the core layer, whose queues give the capacity and expiry semantics above. Third, the SQLite option
transaction_mode set to IMMEDIATE (Django 5.1 and later) takes the write lock when atomic() begins, which the
sequence counter in The ORM half: transactions, sequence numbers, history relies on; PostgreSQL gets the same guarantee from the row lock of the UPDATE.
asgi.py and the handshake
# config/asgi.py
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django_asgi_app = get_asgi_application() # django.setup() before model imports
from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.routing import ChannelNameRouter, ProtocolTypeRouter, URLRouter # noqa: E402
from django.conf import settings # noqa: E402
from chat import routing # noqa: E402
from chat.arena import ArenaWorker # noqa: E402
from chat.security import OptionalOriginValidator # noqa: E402
application = ProtocolTypeRouter(
{
"http": django_asgi_app,
"websocket": OptionalOriginValidator(
AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns)),
settings.CHATTERBOX_ORIGINS,
),
"channel": ChannelNameRouter({"arena": ArenaWorker.as_asgi()}),
}
)
# chat/security.py
from channels.security.websocket import OriginValidator
class OptionalOriginValidator(OriginValidator):
"""Allow-list the Origin header, but let clients that send none through.
Browsers always send Origin, and a hostile page cannot remove it, so the
cross-site hijacking check still holds. Non-browser clients (scripts,
mobile apps) send no Origin and authenticate with a ticket instead.
"""
def valid_origin(self, parsed_origin):
if parsed_origin is None:
return True
return self.validate_origin(parsed_origin)
Source: Routing and Channels: Security
Four choices in these two files deserve a sentence each.
-
"http"is routed explicitly toget_asgi_application(). Channels 4 removedAsgiHandlerand the automatic routing of thehttpprotocol, so a project that forgets the key cannot serve a single page. -
The origin allow-list is the first defence. Browsers do not apply the same-origin policy to WebSocket handshakes and send cookies automatically, so a hostile page can open a socket to your server with your user’s session. The browser always adds an
Originheader and a page cannot remove or forge it, so the validator wraps everything else. The stockOriginValidatoralso refuses a connection with noOriginat all. The Chatterbox protocol accepts non-browser clients that send none, soOptionalOriginValidatorlets those through and leaves their authentication to the ticket. An origin check is not authorisation: a script can send anyOriginit likes, which is why every action is checked again inside the consumer. -
Tickets replace headers. The browser
WebSocketAPI cannot set anAuthorizationheader, and a URL is a poor place for a credential. Chatterbox sends the one-time ticket in the first frame instead (hello.payload.token). A browser that is logged in to the Django site needs no ticket at all: the session cookie authenticates the handshake throughAuthMiddlewareStack, andhellocan omit the token. -
The
"channel"entry maps the worker’s named channel to the game worker (The Pixel Arena game loop).
Source: Channels: Authentication
The data model
Four tables are enough for everything the protocol keeps.
# chat/models.py
from django.conf import settings
from django.db import models
class Room(models.Model):
name = models.CharField(max_length=64, primary_key=True)
last_seq = models.PositiveIntegerField(default=0) # per-room counter
class Membership(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
room = models.ForeignKey(Room, on_delete=models.CASCADE)
class Meta:
constraints = [
models.UniqueConstraint(fields=["user", "room"], name="one_membership")
]
class Event(models.Model):
"""One row per seq: a message, an edit of a message or a reaction."""
class Kind(models.TextChoices):
MESSAGE = "message"
EDIT = "edit"
REACT = "react"
room = models.ForeignKey(Room, on_delete=models.CASCADE)
seq = models.PositiveIntegerField()
kind = models.CharField(max_length=8, choices=Kind)
ref = models.CharField(max_length=36) # message id, or the target of edit/react
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
data = models.JSONField()
ts = models.BigIntegerField() # server time, epoch milliseconds
class Meta:
ordering = ["seq"]
constraints = [
models.UniqueConstraint(fields=["room", "seq"], name="seq_unique_per_room"),
models.UniqueConstraint(
fields=["ref"],
condition=models.Q(kind="message"),
name="message_id_unique",
),
]
class Processed(models.Model):
"""Idempotency table: the first ack of every (user, envelope id)."""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
env_id = models.CharField(max_length=36)
ack = models.JSONField()
created = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=["user", "env_id"], name="one_ack_per_id")
]
Source: Django: constraints and Django: JSONField
-
Room.last_seqis the per-room counter.seqis one number line shared by messages, edits and reactions, so it has gaps in a history page that returns only messages, as the protocol states. -
Eventstores all three ordered kinds.refis the message id for a message and the target message id for an edit or a reaction. The unique constraint on(room, seq)makes a duplicate sequence number impossible even if the counter code were wrong, and the conditional constraint onrefmakes "the id of a message is its id forever" a database rule. -
Processedis the idempotency table:(user, env_id)is unique andackholds the first acknowledgement. The protocol asks the server to remember ids for at least 24 hours; delete rows older than that with a periodic job (thecreatedcolumn is there for it).
The Chatterbox consumer
One class handles the connection. Its shape is a pipeline: size and type checks, a rate limit, envelope validation, then a
dispatch to a method named after the message type. The group handler chat_envelope turns every event published to a
room into one outbound frame.
# chat/consumers.py
import asyncio
import logging
import secrets
import time
from collections import deque
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.contrib.auth import get_user_model
from django.db import IntegrityError
from . import presence, store
from .protocol import (
HEARTBEAT_MS, ID_RE, MAX_FRAME_BYTES, ROOM_RE, SUBPROTOCOL, ChatError,
ack_envelope, envelope, error_envelope, parse_envelope,
) # fmt: skip
log = logging.getLogger("chatterbox")
GRACE_SECONDS = 15 # offline is announced this long after the last socket closes
_background = set() # strong references: the loop only keeps weak ones
def room_group(room):
return f"room-{room}" # [a-z0-9-] is valid in a group name
class TokenBucket:
def __init__(self, rate=20.0, capacity=40.0):
self.rate, self.capacity = rate, capacity
self.tokens, self.stamp = capacity, time.monotonic()
def take(self):
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.stamp) * self.rate)
self.stamp = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
async def announce_offline_later(user_id, name, rooms, layer):
await asyncio.sleep(GRACE_SECONDS)
if await presence.status_of(user_id) != "offline":
return # reconnected during the grace period
for room in rooms:
payload = {"userId": user_id, "name": name, "status": "offline"}
await layer.group_send(
room_group(room),
{"type": "chat.envelope", "envelope": envelope("presence", payload, room),
"exclude_user": user_id},
) # fmt: skip
class ChatConsumer(AsyncJsonWebsocketConsumer):
user = None
uid = None
session_id = None
watchdog_task = None
rooms = ()
closing = False # set once we sent a close: later frames are ignored
# ---- connection lifecycle -------------------------------------------
async def connect(self):
offered = self.scope.get("subprotocols", [])
if offered and SUBPROTOCOL not in offered:
await self.close() # handshake refused: HTTP 403
return
self.rooms = set()
self.bucket = TokenBucket()
self.limit_hits = deque()
self.last_rx = time.monotonic()
self.said_hello = asyncio.Event()
await self.accept(SUBPROTOCOL if offered else None)
self.watchdog_task = asyncio.create_task(self._watchdog())
async def _watchdog(self):
try:
await asyncio.wait_for(self.said_hello.wait(), 5)
except asyncio.TimeoutError:
await self._close(4408) # no hello within 5 s
return
while True:
await asyncio.sleep(HEARTBEAT_MS / 1000)
if time.monotonic() - self.last_rx > 2 * HEARTBEAT_MS / 1000:
await self._close(4408) # two heartbeats of silence
return
async def disconnect(self, code):
if self.watchdog_task:
self.watchdog_task.cancel()
if self.user is None:
return
for room in self.rooms:
await self.channel_layer.group_discard(room_group(room), self.channel_name)
if await presence.drop(self.uid, self.channel_name) == 0:
await presence.save_session(self.session_id, self.uid, presence.SESSION_TTL_S)
task = asyncio.create_task(
announce_offline_later(
self.uid, self.user.get_username(), list(self.rooms), self.channel_layer
)
)
_background.add(task)
task.add_done_callback(_background.discard)
# ---- inbound frames ---------------------------------------------------
async def _close(self, code):
self.closing = True
await self.close(code=code)
async def receive(self, text_data=None, bytes_data=None, **kwargs):
if self.closing:
return # frames queued behind the one that got us closed
self.last_rx = time.monotonic()
if bytes_data is not None or text_data is None:
await self._close(4400) # text frames only
return
if len(text_data.encode()) > MAX_FRAME_BYTES:
await self._send_error(None, None, ChatError("too_large", "frame too large", close=4413))
return
try:
frame = await self.decode_json(text_data)
except ValueError:
await self._send_error(None, None, ChatError("bad_request", "not JSON"))
return
await self.receive_json(frame)
async def receive_json(self, frame, **kwargs):
ref = room = None
if isinstance(frame, dict): # echo ids back only when they look valid
ref = frame["id"] if isinstance(frame.get("id"), str) and ID_RE.match(frame["id"]) else None
room = frame["room"] if isinstance(frame.get("room"), str) and ROOM_RE.match(frame["room"]) else None
try:
self._rate_limit()
await self._dispatch(parse_envelope(frame))
except ChatError as err:
await self._send_error(ref, room, err)
except IntegrityError: # lost a race on a unique constraint
await self._send_error(ref, room, ChatError("conflict", "id already used"))
except Exception:
log.exception("unhandled error in chat consumer")
await self._send_error(ref, room, ChatError("internal", "internal error"))
def _rate_limit(self):
if self.bucket.take():
return
now = time.monotonic()
self.limit_hits.append(now)
while self.limit_hits and now - self.limit_hits[0] > 10:
self.limit_hits.popleft()
close = 4429 if len(self.limit_hits) >= 10 else None
raise ChatError("rate_limited", "slow down", close=close)
async def _send_error(self, ref, room, err):
await self.send_json(error_envelope(ref, room, err))
if err.close:
await self._close(err.close)
async def _dispatch(self, env):
if env["type"] == "hello":
return await self._on_hello(env)
if self.user is None:
raise ChatError("unauthorized", "send hello first", close=4401)
await getattr(self, f"_on_{env['type']}")(env)
# ---- helpers ----------------------------------------------------------
@staticmethod
def _room(env):
if env["room"] is None:
raise ChatError("bad_request", "room is required")
return env["room"]
async def _db(self, fn, *args):
return await database_sync_to_async(fn)(*args)
async def _fanout(self, room, env, exclude_user=None):
await self.channel_layer.group_send(
room_group(room),
{"type": "chat.envelope", "envelope": env, "exclude_user": exclude_user},
)
async def _attach(self, room):
"""Make sure this socket receives the room's events (idempotent)."""
if room not in self.rooms:
self.rooms.add(room)
await self.channel_layer.group_add(room_group(room), self.channel_name)
async def _acked(self, env, handler):
out = await self._db(store.run_once, self.user, env["id"], handler)
return out
async def _finish(self, env, out):
await self.send_json(ack_envelope(env["id"], env["room"], out.seq, out.dup, out.result))
for b in out.broadcast:
await self._fanout(b.room, b.envelope, b.exclude_user)
async def _presence_env(self, room, status):
payload = {"userId": self.uid, "name": self.user.get_username(), "status": status}
return envelope("presence", payload, room)
async def chat_envelope(self, event):
"""Group handler for 'chat.envelope': one frame per subscribed socket."""
if event["exclude_user"] == self.uid:
return
await self.send_json(event["envelope"])
# ---- message types ----------------------------------------------------
async def _on_hello(self, env):
if self.user is not None:
raise ChatError("conflict", "hello was already completed")
payload = env["payload"]
user = self.scope.get("user")
if user is None or not user.is_authenticated: # no cookie: need a ticket
uid = await presence.redeem_ticket(payload.get("token"))
user = await get_user_model().objects.filter(pk=uid, is_active=True).afirst() if uid else None
if user is None:
raise ChatError("unauthorized", "invalid or expired ticket", close=4401)
self.user, self.uid = user, str(user.pk)
resume = payload.get("resume") if isinstance(payload.get("resume"), dict) else {}
resumed = bool(resume) and await presence.session_owner(resume.get("sessionId")) == self.uid
self.session_id = resume["sessionId"] if resumed else secrets.token_hex(16)
before = await presence.status_of(self.uid)
rooms = await self._db(store.rooms_of, user)
for room in rooms:
await self._attach(room)
await presence.beat(self.uid, self.channel_name, "online" if before == "offline" else before)
await presence.save_session(self.session_id, self.uid)
self.said_hello.set()
await self.send_json(envelope("hello", {
"userId": self.uid, "name": user.get_username(), "sessionId": self.session_id,
"heartbeatMs": HEARTBEAT_MS, "maxFrameBytes": MAX_FRAME_BYTES,
"resumed": resumed, "rooms": sorted(rooms), "ref": env["id"],
})) # fmt: skip
if resumed and isinstance(resume.get("rooms"), dict):
for room, last in resume["rooms"].items():
if room in self.rooms and isinstance(last, int):
for item in await self._db(store.replay, room, last):
await self.send_json(item)
if before == "offline":
for room in self.rooms:
await self._fanout(room, await self._presence_env(room, "online"), self.uid)
async def _on_join(self, env):
room = self._room(env)
out = await self._acked(env, lambda: store.join(self.user, room, env["payload"]))
await self._attach(room)
for member in out.result["members"]: # live data, so never cached
member["status"] = await presence.status_of(member["userId"])
await self._finish(env, out)
for item in out.replay:
await self.send_json(item)
if out.joined:
status = await presence.status_of(self.uid)
await self._fanout(room, await self._presence_env(room, status), self.uid)
async def _on_leave(self, env):
room = self._room(env)
out = await self._acked(env, lambda: store.leave(self.user, room))
self.rooms.discard(room)
await self.channel_layer.group_discard(room_group(room), self.channel_name)
await self._finish(env, out)
async def _on_message(self, env):
room = self._room(env)
out = await self._acked(env, lambda: store.post_message(self.user, room, env["id"], env["payload"]))
await self._attach(room)
await self._finish(env, out)
async def _on_edit(self, env):
room = self._room(env)
out = await self._acked(env, lambda: store.edit(self.user, room, env["payload"]))
await self._attach(room)
await self._finish(env, out)
async def _on_react(self, env):
room = self._room(env)
out = await self._acked(env, lambda: store.react(self.user, room, env["payload"]))
await self._attach(room)
await self._finish(env, out)
async def _on_history(self, env):
room = self._room(env)
out = await self._db(store.history, self.user, room, env["payload"])
await self._finish(env, out)
async def _on_presence(self, env):
status = env["payload"].get("status")
if status not in ("online", "away"):
raise ChatError("bad_request", "status must be online or away")
before = await presence.status_of(self.uid)
await presence.beat(self.uid, self.channel_name, status) # also the heartbeat
await self.send_json(ack_envelope(env["id"], env["room"]))
if before != status:
for room in self.rooms:
await self._fanout(room, await self._presence_env(room, status), self.uid)
async def _on_typing(self, env):
room, state = self._room(env), env["payload"].get("state")
if state not in ("start", "stop"):
raise ChatError("bad_request", "state must be start or stop")
if room not in self.rooms:
raise ChatError("forbidden", "join the room first")
if state == "start" and not await presence.allow_typing(self.uid, room):
return # throttled: dropped silently, fire-and-forget
payload = {"userId": self.uid, "state": state}
await self._fanout(room, envelope("typing", payload, room), self.uid)
async def _on_receipt(self, env):
room, p = self._room(env), env["payload"]
if p.get("kind") not in ("delivered", "read") or not isinstance(p.get("upTo"), int):
raise ChatError("bad_request", "kind and upTo are required")
if room not in self.rooms:
raise ChatError("forbidden", "join the room first")
if p["upTo"] > await self._db(store.last_seq, room):
raise ChatError("bad_request", "upTo is beyond the last seq")
payload = {"userId": self.uid, "kind": p["kind"], "upTo": p["upTo"]}
await self._fanout(room, envelope("receipt", payload, room), self.uid)
How the pieces map to the protocol:
-
Handshake.
connect()accepts thechatterbox.v1subprotocol the client offered, and refuses (HTTP 403) a client that offered others. It starts a watchdog that closes with 4408 if nohelloarrives within 5 seconds, then every 20 seconds checks that something arrived in the last 40. A frame that is nothellobefore the hello completes answersunauthorizedand closes with 4401. -
Validation before dispatch.
parse_enveloperejects anything that is not an object withv1, a known type, an id of 26 to 36 characters, a valid room name and an object payload. A wrongvanswersunsupported_versionand closes with 4426. Ids echoed back in an error are checked first, so a hostileidcan never inject text into a reply. -
Frame limits. Binary frames close with 4400. A text frame over 16384 UTF-8 bytes answers
too_largeand closes with 4413. Theclosingflag matters: after the server decides to close, frames that were already queued behind the offending one are ignored. Without it, those frames raiseAttempt to send on a closed protocolin the server log when the 4429 close is sent. -
Rate limit. A token bucket (20 tokens per second, capacity 40) runs before deduplication, so a retry storm is limited too. Ten
rate_limitederrors within ten seconds close the socket with 4429. -
Fan-out.
_fanoutpublishes{"type": "chat.envelope", ...}to the grouproom-<name>. Channels maps the dot to an underscore and callschat_envelopeon every consumer in the group, in every process.exclude_userimplements "others only" for typing, receipts and presence, and the sender’s own copy of a message, edit or reaction is how it learns the sequence number._finishsends the ack first and the broadcast second, but clients must not rely on that order. -
Attach on demand.
_attachjoins the room’s group the first time a socket touches it. A user who joined the room in another tab, or whose membership was loaded athello, therefore always receives the room’s events on this socket. -
Errors. A
ChatErrorbecomes anerrorenvelope withretryableset from the code table; anIntegrityErrorfrom a lost race on a unique constraint becomesconflict; anything else is logged and becomes a retryableinternal.
The ORM half: transactions, sequence numbers, history
All database work is synchronous code in store.py, called through database_sync_to_async (the consumer’s _db
helper), so each call gets a worker thread, a transaction and clean-up of stale connections. The reasons for that choice
over the async ORM are on the reference page; here it also keeps a whole multi-statement operation inside one
transaction.atomic() block, which await points would break.
# chat/store.py (excerpt)
@dataclass
class Broadcast:
room: str
envelope: dict
exclude_user: str | None = None # "others only" types skip the sender
@dataclass
class Outcome:
seq: int | None
result: dict
dup: bool = False
broadcast: list = field(default_factory=list)
replay: list = field(default_factory=list) # for this connection only
joined: bool = False
def run_once(user, env_id, handler):
"""Run handler() at most once per (user, envelope id), atomically.
The ack is stored in the same transaction as the change it acknowledges,
so a crash leaves either both or neither. A ChatError rolls everything
back, which is why errors are never cached.
"""
with transaction.atomic():
done = Processed.objects.filter(user=user, env_id=env_id).first()
if done:
return Outcome(done.ack["seq"], done.ack["result"], dup=True)
outcome = handler()
Processed.objects.create(
user=user, env_id=env_id, ack={"seq": outcome.seq, "result": outcome.result}
)
return outcome
def _next_seq(room):
# UPDATE first: the write lock (BEGIN IMMEDIATE / a row lock) is held
# before the value is read back, so two writers never see the same seq.
Room.objects.filter(pk=room).update(last_seq=F("last_seq") + 1)
return Room.objects.values_list("last_seq", flat=True).get(pk=room)
def post_message(user, room, env_id, payload):
_require_member(user, room)
text = clean_text(payload.get("text"))
reply_to = payload.get("replyTo")
attachments = payload.get("attachments", [])
if not isinstance(attachments, list) or len(attachments) > 5:
raise ChatError("bad_request", "at most 5 attachments")
for a in attachments:
if not (isinstance(a, dict) and str(a.get("url", "")).startswith("https://")):
raise ChatError("bad_request", "attachments need an https url")
if reply_to is not None and not Event.objects.filter(
room_id=room, kind=Event.Kind.MESSAGE, ref=reply_to
).exists():
raise ChatError("not_found", "replyTo is not a message of this room")
if Event.objects.filter(kind=Event.Kind.MESSAGE, ref=env_id).exists():
raise ChatError("conflict", "message id already used")
seq = _next_seq(room)
event = Event.objects.create(
room_id=room, seq=seq, kind=Event.Kind.MESSAGE, ref=env_id, user=user,
ts=now_ms(), data={"text": text, "replyTo": reply_to, "attachments": attachments},
) # fmt: skip
return Outcome(seq, {}, broadcast=[Broadcast(room, _message_env(event))])
def history(user, room, payload):
_require_member(user, room)
before, limit = payload.get("before"), payload.get("limit", 50)
if not isinstance(limit, int) or not 1 <= limit <= 100:
raise ChatError("bad_request", "limit must be 1..100")
rows = Event.objects.filter(room_id=room, kind=Event.Kind.MESSAGE)
if before is not None:
rows = rows.filter(seq__lt=before)
rows = list(rows.order_by("-seq").select_related("user")[: limit + 1])
page, size = [], 0
for event in rows[:limit]:
size += len(json.dumps(_message_env(event)))
if page and size > MAX_PAGE_BYTES:
break
page.append(event)
edits = {} # apply the latest edit of every message in the page
for e in Event.objects.filter(
room_id=room, kind=Event.Kind.EDIT, ref__in=[m.ref for m in page]
).order_by("seq"):
edits[e.ref] = e.data["text"]
messages = [
_message_env(m, edits.get(m.ref), m.ref in edits) for m in reversed(page)
]
has_more = len(rows) > len(page)
nxt = page[-1].seq if has_more else None
return Outcome(None, {"messages": messages, "hasMore": has_more, "next": nxt})
-
run_onceis the idempotency guarantee. The ack is inserted in the same transaction as the event it acknowledges. A crash leaves both or neither. A resent id finds the row and returns the originalseqandresultwithduptrue; the handler does not run again and nothing is broadcast. AChatErrorraised inside the handler rolls the transaction back, so errors are never cached, exactly as the protocol requires. -
_next_sequpdates before it reads. TheUPDATE ... SET last_seq = last_seq + 1takes the write lock first, then the value is read inside the same transaction, so two concurrent writers cannot receive the same number. A read-increment-write in Python would race. -
History is a keyset page on
seq.beforeis exclusive, the query fetcheslimit + 1rows to learnhasMorewithout a count, andnextis theseqof the oldest message returned. The server keeps a page under 250000 bytes by returning fewer messages, because the protocol tells clients to accept at least 262144. Edits are applied on the way out, so the stored message is never rewritten. -
Reads and heartbeats are not deduplicated.
historyandpresenceare idempotent by nature, and storing one row per 20-second heartbeat would fill the table, so their acks are alwaysdupfalse. The protocol allows a server to treat repeats of side-effect-free requests this way; the state-changing types (join,leave,message,edit,react) are all deduplicated. -
Usernames are not identities. The protocol’s
userIdis the primary key as a string. Direct-message rooms are nameddm-<id>-<id>with numeric ids, andjoinchecks that the caller’s id is one of the two.
Presence with expiry in Redis
Presence answers "who is online" without trusting disconnect(), which does not run when a process is killed or a
network vanishes. Every piece of presence state therefore has a lifetime.
# chat/presence.py
"""Presence in Redis. Every key expires on its own, so a crashed worker cannot
leave a user online forever. Nothing here ever calls KEYS or SCAN."""
from .protocol import HEARTBEAT_MS
from .redis_client import get_redis
TTL_MS = 2 * HEARTBEAT_MS # two missed heartbeats = gone
TICKET_TTL_S = 30
SESSION_TTL_S = 120
async def _now_ms(r):
sec, usec = await r.time() # Redis clock: no skew between app servers
return sec * 1000 + usec // 1000
async def beat(user_id: str, channel: str, status: str) -> None:
"""Register or refresh one connection of a user."""
r = get_redis()
now = await _now_ms(r)
async with r.pipeline(transaction=True) as p:
p.zadd(f"cb:conns:{user_id}", {channel: now + TTL_MS})
p.pexpire(f"cb:conns:{user_id}", TTL_MS * 2)
p.set(f"cb:status:{user_id}", status, px=TTL_MS * 2)
await p.execute()
async def drop(user_id: str, channel: str) -> int:
"""Remove one connection; return how many live connections remain."""
r = get_redis()
await r.zrem(f"cb:conns:{user_id}", channel)
return await live_connections(user_id)
async def live_connections(user_id: str) -> int:
r = get_redis()
now = await _now_ms(r)
key = f"cb:conns:{user_id}"
await r.zremrangebyscore(key, "-inf", now) # prune crashed connections
return await r.zcard(key)
async def status_of(user_id: str) -> str:
if await live_connections(user_id) == 0:
return "offline"
return await get_redis().get(f"cb:status:{user_id}") or "online"
async def allow_typing(user_id: str, room: str) -> bool:
"""At most one typing 'start' per user and room every 3 seconds."""
return bool(await get_redis().set(f"cb:typing:{user_id}:{room}", 1, nx=True, px=3000))
async def issue_ticket(user_id: str) -> str:
import secrets
token = secrets.token_urlsafe(24)
await get_redis().set(f"cb:ticket:{token}", user_id, ex=TICKET_TTL_S)
return token
async def redeem_ticket(token) -> str | None:
if not isinstance(token, str) or not 8 <= len(token) <= 64:
return None
return await get_redis().getdel(f"cb:ticket:{token}") # one use only
async def save_session(session_id: str, user_id: str, ttl: int = 86400) -> None:
await get_redis().set(f"cb:session:{session_id}", user_id, ex=ttl)
async def session_owner(session_id) -> str | None:
if not isinstance(session_id, str):
return None
return await get_redis().get(f"cb:session:{session_id}")
# chat/redis_client.py
import asyncio
import weakref
from django.conf import settings
from redis.asyncio import Redis
_clients = weakref.WeakKeyDictionary()
def get_redis() -> Redis:
"""One client per running event loop (each test gets its own loop)."""
loop = asyncio.get_running_loop()
client = _clients.get(loop)
if client is None:
client = _clients[loop] = Redis.from_url(settings.REDIS_URL, decode_responses=True)
return client
Source: ZADD, ZREMRANGEBYSCORE, SET, GETDEL and TIME
The design in a few lines:
-
A user has one sorted set
cb:conns:<userId>whose members are channel names and whose scores are expiry timestamps in milliseconds.beatadds or refreshes a member;live_connectionsfirst removes the members whose score is in the past, then counts. A crashed server’s connections disappear after two heartbeat intervals (40 seconds) with nobody doing anything. The key itself also expires, so an abandoned user leaves no residue. -
The current status (
onlineoraway) is a plain string key with a timeout. A user isofflinewhen no live connection remains, whatever the string says. -
The clock is Redis’s own (
TIME), so application servers with skewed clocks agree. -
GETDEL(tickets) needs Redis 6.2 or newer; channels_redis itself needs 5.0 or newer. -
Nothing calls
KEYS. TheKEYScommand scans the whole keyspace and blocks Redis while it runs, which is why the reference page warns against polling it for presence. -
The 15-second grace period is a task started in
disconnect()when the last connection closes: it sleeps, checks the status again and, if the user is still offline, publishesofflineto the rooms. A reload therefore never flickers. The task lives in the process that saw the last disconnect. If that process dies during the grace period noofflinebroadcast is sent, but every laterjoinandstatus_ofcall still computes the truth from the expiring keys, so the error is a delayed announcement, never a wrong answer. -
get_redis()caches one client per running event loop. A redis-py asyncio client is bound to the loop that created it, and the Django test runner starts a new loop for each async test.
Typing, receipts and resume
Typing. The protocol allows one start per three seconds per user and room and expects clients to throttle. The server
does not trust that: allow_typing runs SET key 1 NX PX 3000 and drops the frame silently when the key already exists,
which is cheap, race-free and shared by every process. stop is never throttled. Typing, like receipts, is
fire-and-forget, so errors for it go back with ref set but no ack ever exists.
Receipts. A receipt is cumulative (upTo). The consumer rejects an upTo beyond the room’s last_seq with
bad_request and fans the receipt out to the other members. The receipts are not stored; a client that was offline asks
history for the messages and computes its own unread count.
Resume. hello returns a sessionId. When the last connection closes the session key is given a 120-second life; a
hello that presents the same id, from the same user, within that time gets resumed true and a replay of every
message, edit and reaction whose seq exceeds the lastSeq the client reports for each room it is still a member of.
After 120 seconds resumed is false and the client joins again with since. The conformance run checks that a message
posted while a client was away arrives with its original seq after the resumed hello.
The Pixel Arena game loop
A game loop must tick at a fixed rate regardless of how many sockets are open, so it does not belong in a consumer (one
instance per connection) and should not share a process with request handling. Channels has a place for it: a worker.
manage.py runworker listens on named channels and feeds each event to a consumer routed by ChannelNameRouter. The web
process forwards player input to the channel arena and receives snapshots through a group; the worker owns the world.
# chat/arena.py
"""Pixel Arena: an authoritative 20 Hz game loop in a channel-layer worker."""
import asyncio
import time
from channels.consumer import AsyncConsumer
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from . import presence
TICK_HZ = 20
SPEED = 120.0 # pixels per second
WIDTH, HEIGHT = 800, 600
STATE_GROUP = "arena-state"
class ArenaWorker(AsyncConsumer):
"""Runs under `manage.py runworker arena`; receives events sent to "arena".
One process owns the world, so run exactly one of these per arena.
"""
channel_layer_alias = "game"
players = {} # player id -> {"x", "y", "dx", "dy", "ack"}; class-level: one world
tick = 0
loop_task = None
async def arena_join(self, event):
self.players.setdefault(event["player"], {"x": 400.0, "y": 300.0, "dx": 0, "dy": 0, "ack": 0})
self._ensure_loop()
async def arena_leave(self, event):
self.players.pop(event["player"], None)
async def arena_input(self, event):
p = self.players.get(event["player"])
if p is not None and event["seq"] > p["ack"]:
p["dx"], p["dy"], p["ack"] = event["dx"], event["dy"], event["seq"]
def _ensure_loop(self):
if ArenaWorker.loop_task is None or ArenaWorker.loop_task.done():
ArenaWorker.loop_task = asyncio.create_task(self._run())
async def _run(self):
dt = 1 / TICK_HZ
due = time.monotonic()
while self.players:
for p in self.players.values(): # simulate with the latest input
p["x"] = min(WIDTH, max(0.0, p["x"] + p["dx"] * SPEED * dt))
p["y"] = min(HEIGHT, max(0.0, p["y"] + p["dy"] * SPEED * dt))
ArenaWorker.tick += 1
snapshot = {pid: [round(p["x"], 1), round(p["y"], 1), p["ack"]] for pid, p in self.players.items()}
await self.channel_layer.group_send(
STATE_GROUP,
{"type": "arena.snapshot", "tick": self.tick, "players": snapshot},
)
due += dt # fixed-step schedule: no drift when a tick runs long
await asyncio.sleep(max(0.0, due - time.monotonic()))
class ArenaConsumer(AsyncJsonWebsocketConsumer):
"""The player's socket: forwards inputs to the worker, relays snapshots."""
channel_layer_alias = "game"
player = None
deadline = None
async def connect(self):
await self.accept()
user = self.scope["user"]
if user.is_authenticated: # browser with a session cookie
await self._enter(str(user.pk))
else: # script or app: first frame must be {"type": "hello", "token": ...}
self.deadline = asyncio.get_running_loop().call_later(
5, lambda: asyncio.create_task(self.close(code=4401))
)
async def _enter(self, player):
if self.deadline:
self.deadline.cancel()
self.player = player
await self.channel_layer.group_add(STATE_GROUP, self.channel_name)
await self.channel_layer.send("arena", {"type": "arena.join", "player": player})
await self.send_json({"type": "welcome", "player": player, "tickHz": TICK_HZ})
async def disconnect(self, code):
if self.deadline:
self.deadline.cancel()
if self.player is not None:
await self.channel_layer.group_discard(STATE_GROUP, self.channel_name)
await self.channel_layer.send("arena", {"type": "arena.leave", "player": self.player})
async def receive_json(self, content, **kwargs):
if self.player is None:
player = await presence.redeem_ticket(content.get("token") if isinstance(content, dict) else None)
if player is None:
await self.close(code=4401)
else:
await self._enter(player)
return
try:
seq, dx, dy = int(content["seq"]), int(content["dx"]), int(content["dy"])
except (KeyError, TypeError, ValueError):
return # ignore malformed input, never crash the loop
await self.channel_layer.send(
"arena",
{"type": "arena.input", "player": self.player, "seq": seq,
"dx": max(-1, min(1, dx)), "dy": max(-1, min(1, dy))},
) # fmt: skip
async def arena_snapshot(self, event):
await self.send_json({"type": "snapshot", "tick": event["tick"], "players": event["players"]})
The compose file starts the worker with python manage.py runworker --layer game arena.
-
--layer gameis required.runworkerlistens on thedefaultlayer unless told otherwise, while the consumers send to thegamealias. Without the flag the worker started cleanly, listened on the wrong layer, and the client received a welcome message and never a snapshot. Nothing logged an error. -
The loop starts lazily. A worker consumer has no start-up hook, so the first
arena_joincreates the loop task. Channels dispatches one event at a time to a consumer, but the task runs concurrently with that dispatch, and all state is touched from the single event-loop thread, so no lock is needed. -
Fixed-step timing.
due += dtschedules ticks against a running deadline rather than sleeping a fixed 50 ms after the work, so a slow tick does not stretch the whole timeline. The loop ends when the last player leaves. -
Full snapshots, not deltas. Each snapshot is
{"tick", "players": {id: [x, y, lastAckedInputSeq]}}. Because every snapshot is complete and carries the highest input sequence the server has applied, a snapshot that expires or is skipped by a full channel costs nothing, and a client can reconcile its prediction from any snapshot (see Latency Compensation). -
One worker per world. The state is in the memory of one process. Running two workers on the same channel would split the players between two worlds, so scale by running one worker per arena name instead and have the consumer pick the channel name. The worker’s own failure loses the world: persist anything that matters elsewhere.
-
At most once. The documentation is explicit that background channels give no retries and no return values. For a game that is correct (the next input replaces a lost one); for work that must complete, use a task queue.
-
Input is clamped on the server. The consumer converts
dxanddyto integers in -1 to 1 and ignores malformed frames, so a client cannot move faster by sendingdx50. The same rule that applies to chat applies to the game: never trust the client with state.
The full architecture of such a game is in Multiplayer Game Architecture, a framework alternative is in Game Servers and Colyseus, and the complete application is built in Building a Multiplayer Game.
Running it and proving it
docker compose -p chatterbox up -d --build # redis, web (daphne) and worker
node client.mjs # 36 checks, under a minute
docker compose -p chatterbox down -v # stop and delete the volumes
The conformance client uses Node’s global WebSocket only. It asks the web container for a one-time ticket per user
(manage.py mint_ticket alice prints the user id and the ticket), then drives the server and asserts on the frames. The
helper class and two of the sections, the message with its acknowledgement and the duplicate, show the method:
// client.mjs (excerpt)
// Conformance run for the Django Chatterbox server (Node 24, global WebSocket).
import { execFileSync } from "node:child_process";
import { setTimeout as sleep } from "node:timers/promises";
const PORT = process.env.WEB_PORT ?? "8000";
const URL = `ws://127.0.0.1:${PORT}/ws`;
const PROJECT = process.env.COMPOSE_PROJECT ?? "chatterbox";
let failures = 0;
const check = (ok, label) => { console.log(`${ok ? "PASS" : "FAIL"} ${label}`); if (!ok) failures++; };
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
const ulid = () => Array.from({ length: 26 }, () => alphabet[Math.floor(Math.random() * 32)]).join("");
function ticket(name) {
const out = execFileSync("docker", ["compose", "-p", PROJECT, "exec", "-T", "web", "python", "manage.py", "mint_ticket", name]).toString().trim();
const [id, token] = out.split(" ");
return { id, token };
}
class Client {
frames = []; closed = null;
constructor(path = URL, protocol = "chatterbox.v1") {
this.ws = protocol ? new WebSocket(path, protocol) : new WebSocket(path);
this.opened = new Promise((res, rej) => { this.ws.onopen = res; this.ws.onerror = () => rej(new Error("connect failed")); });
this.ws.onmessage = (e) => this.frames.push(JSON.parse(e.data));
this.ws.onclose = (e) => { this.closed = e.code; };
}
send(type, payload = {}, room = null, id = ulid()) {
this.ws.send(JSON.stringify({ v: 1, type, id, room, ts: 0, payload })); return id;
}
async next(pred, ms = 2000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const i = this.frames.findIndex(pred);
if (i >= 0) return this.frames.splice(i, 1)[0];
await sleep(10);
}
return null;
}
ack = (id, ms) => this.next((f) => f.type === "ack" && f.payload.ref === id, ms);
error = (id, ms) => this.next((f) => f.type === "error" && f.payload.ref === id, ms);
async closedWith(ms = 2000) { const end = Date.now() + ms; while (this.closed === null && Date.now() < end) await sleep(20); return this.closed; }
}
async function login(name) {
const t = ticket(name);
const c = new Client(); await c.opened;
c.send("hello", { token: t.token, client: "node/24" });
const hello = await c.next((f) => f.type === "hello");
return { c, hello, t };
}
// ... sections 1-4: handshake errors, hello, ticket replay, join and presence ...
// 5. message, broadcast, ack
const mid = ulid();
a.send("message", { text: "Hello, Chatterbox", replyTo: null, attachments: [] }, "general", mid);
ack = await a.ack(mid);
check(ack?.payload.ok && ack.payload.seq === 1 && ack.payload.dup === false, "message -> ack seq 1, dup false");
const toBob = await b.next((f) => f.type === "message" && f.id === mid);
const toAlice = await a.next((f) => f.type === "message" && f.id === mid);
check(toBob?.payload.seq === 1 && toBob.payload.from.name === "alice" && toAlice, "broadcast reaches bob and the sender, id = message id");
// 6. duplicate
a.send("message", { text: "Hello, Chatterbox", replyTo: null, attachments: [] }, "general", mid);
ack = await a.ack(mid);
check(ack?.payload.dup === true && ack.payload.seq === 1, "same id again -> ack dup:true, same seq");
check((await b.next((f) => f.type === "message", 400)) === null, "duplicate is not broadcast again");
// ... sections 7-15 ...
Source: MDN: WebSocket constructor and MDN: CloseEvent.code
The complete script covers the following behaviours, all of which passed against Daphne and against Uvicorn:
| Area | Checks |
|---|---|
Handshake |
frame before hello ( |
Rooms and presence |
join returns members with status; presence for a newcomer; presence away is acked and broadcast; |
Messages |
ack with |
Errors |
post to an unjoined room ( |
Edits, reactions, typing, receipts |
edit and reaction get the next |
History and resume |
two pages of history, ascending, |
Limits |
17000-byte frame ( |
Pixel Arena |
welcome after ticket; about 20 snapshots in one second; ticks increase; the player moved and its input sequence is acknowledged while the other did not move |
Source: RFC 6455: The WebSocket Protocol
Testing with WebsocketCommunicator
The conformance script tests the deployed system. Unit tests run in-process with WebsocketCommunicator and need no
server socket. The tests below use the in-memory channel layer and the Redis service of the compose file (presence and
tickets use Redis directly), and are run with docker compose run --rm web python manage.py test chat.
# chat/tests/test_chat.py
from unittest.mock import AsyncMock, patch
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.contrib.auth import get_user_model
from django.test import TransactionTestCase, override_settings
from chat import routing
from chat.protocol import SUBPROTOCOL
IN_MEMORY = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
"game": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
ID = "01JAB3W7Q8Z4N5R6T7V8X9Y0Z{}" # 26 characters once formatted
def frame(type_, payload=None, room=None, n="A"):
return {"v": 1, "type": type_, "id": ID.format(n), "room": room, "ts": 0, "payload": payload or {}}
# presence and tickets use Redis (the compose service); the layer is in memory
@override_settings(CHANNEL_LAYERS=IN_MEMORY)
@patch("chat.consumers.announce_offline_later", new=AsyncMock())
class ChatConsumerTests(TransactionTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user("alice")
self.bob = User.objects.create_user("bob")
async def client_for(self, user):
router = URLRouter(routing.websocket_urlpatterns)
c = WebsocketCommunicator(router, "/ws", subprotocols=[SUBPROTOCOL])
c.scope["user"] = user # skip cookies: the stack has its own tests
connected, subprotocol = await c.connect()
assert connected and subprotocol == SUBPROTOCOL
await c.send_json_to(frame("hello", n="H"))
assert (await c.receive_json_from())["type"] == "hello"
return c
async def until(self, client, type_):
"""Next frame of the given type (presence frames can arrive in between)."""
while (item := await client.receive_json_from())["type"] != type_:
pass
return item
async def test_message_is_acked_broadcast_and_deduplicated(self):
a, b = await self.client_for(self.alice), await self.client_for(self.bob)
for c, n in ((a, "J"), (b, "K")):
await c.send_json_to(frame("join", {"since": None}, "general", n))
await self.until(c, "ack")
await self.until(a, "presence") # alice sees bob arrive
msg = frame("message", {"text": "Hello, Chatterbox"}, "general", "M")
await a.send_json_to(msg)
ack = await self.until(a, "ack")
self.assertEqual((ack["payload"]["seq"], ack["payload"]["dup"]), (1, False))
self.assertEqual((await self.until(b, "message"))["payload"]["text"], "Hello, Chatterbox")
await a.send_json_to(msg) # same id again
dup = await self.until(a, "ack")
self.assertEqual((dup["payload"]["dup"], dup["payload"]["seq"]), (True, 1))
self.assertTrue(await b.receive_nothing()) # nothing new for bob
await a.disconnect()
await b.disconnect()
async def test_non_member_cannot_post(self):
a = await self.client_for(self.alice)
await a.send_json_to(frame("message", {"text": "hi"}, "secret"))
reply = await a.receive_json_from()
self.assertEqual((reply["type"], reply["payload"]["code"]), ("error", "forbidden"))
await a.disconnect()
async def test_frame_before_hello_closes_with_4401(self):
router = URLRouter(routing.websocket_urlpatterns)
c = WebsocketCommunicator(router, "/ws")
c.scope["user"] = self.alice
await c.connect()
await c.send_json_to(frame("message", {"text": "hi"}, "general"))
self.assertEqual((await c.receive_json_from())["payload"]["code"], "unauthorized")
self.assertEqual((await c.receive_output())["code"], 4401)
Source: Channels: Testing and Django: Writing and running tests
-
Route to the consumer, not to
asgi.application.AuthMiddlewareStackoverwritesscope["user"]from the session, so a test that injects a user must buildURLRouter(routing.websocket_urlpatterns)itself. The full stack has separate tests, as on the reference page. -
TransactionTestCase. The consumer touches the database from worker threads, which aTestCasetransaction on the main thread cannot share. -
Skip frames you are not asserting on. Presence frames interleave with acks; the
untilhelper reads until the wanted type arrives. Remember that it discards what it skips. -
receive_nothing()proves absence. It is how the duplicate test shows that bob gets no second broadcast. -
Patch the grace task.
announce_offline_latersleeps 15 seconds; the class decorator replaces it with anAsyncMockso no task outlives its event loop. -
Channels 4.3 changed
ApplicationCommunicatortimeouts. A call that used to end with a timeout when the application had already finished now surfacesasyncio.CancelledError, so a test that expects the application to stop should assert that error (4.3.0 release notes). Tests also should not close database connections: since 4.2 Channels leaves them open in tests.
Daphne or Uvicorn
The same config.asgi:application ran under both servers and the whole conformance script passed against each.
| Daphne 4.2.3 | Uvicorn 0.54.0 | |
|---|---|---|
Install |
|
|
Start |
|
|
Lifespan |
no lifespan events are sent, nothing is logged |
|
Keep-alive pings |
|
|
Message size |
|
|
Processes |
one per process; run several behind a proxy or with a supervisor |
|
Fit for this page |
the reference server of Channels; required by |
common in container deployments; HTTP/1.1 only, while Daphne’s start-up log notes that HTTP/2 needs its |
Both send protocol-level pings every 20 seconds, which matches the 20000 ms heartbeatMs the hello reply announces; the
application-level watchdog (two heartbeats of silence) is a separate, protocol-level check that also catches a client
whose WebSocket library answers pings but whose application is stuck. Neither server is a reason to change the consumer
code. Choose with the operational questions of
Proxies, Load Balancers and Deployment, and size
the number of processes with
Scaling WebSockets.
Source: Channels: Deploying, Daphne and Uvicorn settings
Channels 4.x changes that matter here
-
Channels 4.0. Daphne became optional: install
channels[daphne]and listdaphnebeforedjango.contrib.staticfiles(a system check enforces it).AsgiHandlerandAsgiRequestwere removed, so route"http"toget_asgi_application().runworkerstayed in thechannelsapp, which is whychannelsis inINSTALLED_APPSof this project. -
Channels 4.2. Async consumers close stale database connections before a new connection is opened, after a client message arrives and after disconnect;
aclose_old_connectionsis for the quiet time in between. With Django 5.1 or later Channels uses Django’s async session interface.InMemoryChannelLayerhonours expiry and capacities, and Django 5.1 is supported. -
Channels 4.3.
ApplicationCommunicatorreturns its result when the application has already finished and tests that relied on a timeout should assertCancelledError. The minimum Python is 3.9 and asgiref 3.9. -
Channels 4.3.2 (2025-11-20) is the current release. It does not yet list Django 6.1, and it ran on 6.1.2 here.
-
channels_redis 4.3.0 works with redis-py 7.4.1; redis-py 8.x currently does not (Dependencies, container image and Compose file).
Common errors and pitfalls
-
A worker that never sees a message.
runworkerwithout--layerlistens on thedefaultalias (The Pixel Arena game loop). -
TimeoutErrorfrom the Redis layer after a dependency update. redis-py 8’s 5-second socket timeout against channels_redis’s blocking reads (Dependencies, container image and Compose file). Pinredis<8. -
An unrouted URL. The outermost
URLRouterraisesValueError: No route found, and Daphne answers the handshake with HTTP 500; add a catch-all if probes are expected. -
Committed but never announced. The consumer commits, sends the ack, then broadcasts. A crash between commit and broadcast leaves a stored message that nobody saw live, and a retry of the same id returns
duptrue without broadcasting. The remedies are the protocol’s own: clients detect aseqgap and askhistoryorjoinwithsince, and a transactional outbox is the next step if live delivery must be exact. -
Blocking in async code. A synchronous ORM call in
receive_jsonraisesSynchronousOnlyOperation; all database work here goes throughstore.py. -
group_sendwith a type that has no handler. A typo in the event type (chat.envelop) ends the receiving connection withValueError: No handler for message type. Keep the type names in one module. -
Non-serialisable events. The Redis layers serialise with msgpack; tuples return as lists and a
datetimeraisesTypeError. The in-memory layer in tests hides this, so send only strings, numbers, booleans,None, lists and dicts. -
Group membership is not presence. A group member may be gone; only the expiring keys know.
-
Many processes with the in-memory layer. Two Daphne processes with
InMemoryChannelLayernever exchange events. -
Stale users on long connections. The
scope["user"]was read at the handshake. Re-check the session before privileged actions, as shown on the reference page.
Security checklist
-
Allow-list
Origin(browsers) and require a one-time ticket or a session cookie (every client); an origin check alone is not authentication. -
Tickets live 30 seconds, are consumed by
GETDEL, and travel in the first frame, not in the URL, so they do not reach access logs. The signed, stateless alternative on the reference page is simpler but replayable within its lifetime; pick the one-time form when you already run Redis. -
Validate every inbound field (type, length, room name, id format) and authorise every action against the database (membership, authorship). The consumer never trusts a user id from the payload.
-
Limit frame size, frame rate and attachment count; refuse non-HTTPS attachment URLs; always render received text with
textContent, neverinnerHTML. -
Run behind TLS (
wss://) and a proxy that forwardsUpgrade; see the deployment and security pages of this section.
Related pages
-
Channels & WebSockets (the Django reference this page builds on)
-
Message Design and Serialization (the protocol)
-
Presence, Typing and Receipts and History, Offline Delivery and Moderation
-
Redis streams and Pub/Sub and FastAPI WebSockets (the same protocol on another Python stack)
References
-
Redis: ZADD and SET