Channels & WebSockets
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1 on PyPI, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06), Python
3.12-3.14, Channels 4.3.2 (2025-11-20), channels_redis 4.3.0 (2025-07-22) and Daphne 4.2.3 (2026-07-21), all checked on
2026-10-03. Channels 4.3.2 lists Django 6.0 as its newest confirmed version and has no Django 6.1 classifier yet; it
installed and ran under Django 6.1.1 in the scratch project used for this page, and it also supports the 5.2 LTS
(official since Channels 4.2.1), so nothing below changes between 5.2 and 6.1. Every listing was run in that scratch
project on Python 3.13 and Django 6.1.1 with SQLite: manage.py check (also under -W error) passes, the 23 tests shown
or described pass with Django’s test runner, and the same asgi.py was served by Daphne 4.2.3 (and, for one echo
message, by Uvicorn) and exercised with a real WebSocket client, curl and Node’s built-in WebSocket. No Redis server
was available: every test uses InMemoryChannelLayer, and the channels_redis configuration was only instantiated
(no connection was opened), so the Redis behaviour described here comes from the Channels and channels_redis
documentation and source, not from a run.
Plain Django answers one HTTP request with one response and forgets the connection. Some features need the opposite: a
chat room, a live "new review" feed, a progress bar, a dashboard that updates by itself — a connection that stays open
for minutes or hours and carries messages in both directions. Django itself deliberately does not speak WebSocket: its
ASGI handler refuses everything except HTTP (it raises ValueError: Django can only handle ASGI/HTTP connections).
Channels is the official Django project that fills that gap. It adds routing by connection type, consumers (the
long-lived counterpart of a view), a channel layer that lets one consumer talk to others, and authentication, sessions
and origin checks that reuse Django’s own. This page covers all of it, using the Bookshelf application that grows across
this section: a per-book discussion room, a staff notification stream and a live review feed.
In a hurry: what goes where
| Need | Rule of thumb | Section |
|---|---|---|
Install it |
|
|
Route connections |
|
|
Handle a WebSocket |
Subclass |
|
Keep an HTTP response open |
|
|
Broadcast to many clients |
|
|
Push from a view, signal or task |
|
|
Know who is connected |
|
|
Stop cross-site hijacking |
Wrap the WebSocket router in |
|
Run it |
|
|
Test it |
|
|
Upgrade an old project |
Follow the checklist in Migrating from Channels 2 to Channels 4. |
Where Channels sits in the request cycle
A normal request travels through the middleware chain to a view and back (the whole path is in
The Request/Response Cycle). A WebSocket does not: the protocol server
(Daphne, Uvicorn, …) accepts the TCP connection, reads the HTTP upgrade request, and calls your ASGI application once
for the whole connection. ASGI describes that call as application(scope, receive, send): a scope dict (what this
connection is: its type, path, headers, query_string, client, subprotocols, …) and two awaitables to
receive events from the client and send events back. For a WebSocket the events are websocket.connect,
websocket.receive, websocket.disconnect (from the client) and websocket.accept, websocket.send, websocket.close
(from you). One scope is one connection, and one consumer instance serves it for its entire life — which is why a
consumer can keep per-connection state on self, and why Django’s per-request middleware does not run: Channels brings
its own middleware (sessions, authentication, origin checks) that works on the scope.
Plain HTTP can use the same door. With the router in asgi.py: ProtocolTypeRouter, URLRouter and as_asgi() the "http" entry sends ordinary requests to Django’s
own ASGI handler (so views, middleware and templates are unchanged) and can send a few paths to Channels consumers, such
as a server-sent-events stream.
The diagram shows the two halves of Channels. The connection half (steps 1-6, 8 and 14) is plain ASGI: one consumer, one socket. The layer half (steps 7, 9-12 and 15) is what makes it a multi-user system: consumers never hold references to each other, they only publish events to named groups or channels in the channel layer, and every consumer that is subscribed turns the event into its own frame. The consumers can live in different server processes or machines as long as they share a layer such as Redis.
scope['type']"} pt -->|http| hr["URLRouter (http)"] hr -->|"feed/reviews/"| sse["ReviewFeedConsumer"] hr -->|"everything else"| dj["Django ASGI handler
middleware, views"] pt -->|websocket| ov["AllowedHostsOriginValidator"] ov --> am["AuthMiddlewareStack
cookies, session, scope['user']"] am --> tk["TicketAuthMiddleware (custom)"] tk --> wr["URLRouter (websocket)"] wr -->|"ws/echo/"| e["EchoConsumer"] wr -->|"ws/books/id/discussion/"| d["DiscussionConsumer"] wr -->|"ws/notifications/"| n["NotificationConsumer"] pt -.->|"channel (workers only)"| cn["ChannelNameRouter"]
Installing and configuring Channels
Install Channels with the Daphne extra, plus the Redis channel layer for production:
python -m pip install -U 'channels[daphne]' channels-redis
The daphne extra pulls in daphne>=4.0.0. Daphne is optional since Channels 4: without the extra you install any other
ASGI server yourself (Other ASGI servers). channels_redis is a separate package with its own releases. Then three
settings:
# config/settings.py (excerpt)
INSTALLED_APPS = [
"daphne", # must come before django.contrib.staticfiles
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"channels", # optional: only needed for the runworker command
"catalog",
]
ALLOWED_HOSTS = ["localhost", "127.0.0.1"] # also drives AllowedHostsOriginValidator
ASGI_APPLICATION = "config.asgi.application"
CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
-
"daphne"inINSTALLED_APPSreplacesrunserverwith an ASGI version (somanage.py runserverserves WebSockets in development). It must precededjango.contrib.staticfiles; otherwise the check framework fails withdaphne.E001: Daphne must be listed before django.contrib.staticfiles in INSTALLED_APPS(reproduced on 2026-10-03). Any other app that replacesrunserver(the documentation nameswhitenoise.runserver_nostatic) conflicts for the same reason: keepdaphneat the top. -
"channels"inINSTALLED_APPSis optional in Channels 4: it only registers therunworkercommand (Background workers). Channels 2 required it forrunserver; that role moved to Daphne. -
ASGI_APPLICATIONis the dotted path of the root application. Thedaphnecommand and other ASGI servers take the module path on their command line and ignore it, but Daphne’srunserverandrunworkerread it: without itrunserverstops withCommandError: You have not set ASGI_APPLICATION, which is needed to run the server. -
CHANNEL_LAYERSis optional. Leave it unset (or{}) if you only need independent WebSockets; consumers then have nochannel_layer, and agroupsattribute raisesInvalidChannelLayerErroron connect (Channel layers and groups). -
ALLOWED_HOSTSis Django’s own setting;AllowedHostsOriginValidatorreuses it (Origin validation).
Production values (the Redis host, a sharing key) come from the environment, never from the repository; the switch is shown in [redis-config].
The running example
Two small models support the examples: a Book and the messages posted in its discussion room (the live feed and the
notifications need no tables). The project is config, the app catalog; project layout, settings and models are
explained in Project Structure & Apps,
Settings & Configuration and
Models & Fields.
# catalog/models.py
from django.conf import settings
from django.db import models
class Book(models.Model):
title = models.CharField(max_length=200)
def __str__(self):
return self.title
class DiscussionMessage(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="messages")
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
text = models.CharField(max_length=500)
created = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["created"]
The files this page adds to the app are:
config/
asgi.py # root application: routers and middleware
settings.py # daphne, ASGI_APPLICATION, CHANNEL_LAYERS
urls.py # ordinary HTTP views that push events
catalog/
consumers.py # Echo, Discussion, Notification, ReviewFeed consumers
lowlevel.py # raw SyncConsumer / AsyncConsumer examples
middleware.py # signed-ticket authentication middleware
routing.py # websocket_urlpatterns and http_urlpatterns
views.py # views that publish to the channel layer
workers.py # a background-worker consumer
tests/ # WebsocketCommunicator and friends
asgi.py: ProtocolTypeRouter, URLRouter and as_asgi()
startproject already wrote config/asgi.py; Channels asks you to extend it, not replace the Django part. The root
object is a router — itself an ASGI application — that looks at scope["type"] and hands the connection to the
right sub-application.
# config/asgi.py
"""ASGI entry point: HTTP goes to Django, WebSockets go to Channels."""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
# Initialise Django (and the app registry) before importing anything that
# touches models: consumers, auth middleware and the routing module.
django_asgi_app = get_asgi_application()
from channels.auth import AuthMiddlewareStack # noqa: E402
from channels.routing import ProtocolTypeRouter, URLRouter # noqa: E402
from channels.security.websocket import AllowedHostsOriginValidator # noqa: E402
from django.urls import re_path # noqa: E402
from catalog import routing # noqa: E402
from catalog.middleware import TicketAuthMiddleware # noqa: E402
application = ProtocolTypeRouter(
{
# long-lived HTTP endpoints first, everything else falls through to Django
"http": URLRouter(
[
*routing.http_urlpatterns,
re_path(r"", django_asgi_app),
]
),
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(
TicketAuthMiddleware(URLRouter(routing.websocket_urlpatterns))
)
),
}
)
Source: Routing and How to deploy with ASGI
Four details in that file are not decoration.
-
get_asgi_application()runs first. It callsdjango.setup(), which populates the app registry. Anything that imports a model (consumers,AuthMiddlewareStack, the routing module) must be imported after it, which is why those imports sit below the call withnoqa: E402. Importing them earlier fails withAppRegistryNotReady: Apps aren’t loaded yet.(or, when the settings module is not even set, theImproperlyConfigurederror the Channels troubleshooting page quotes); that page shows the same ordering. -
The
"http"key is explicit. In Channels 2 an unrouted HTTP request went to a bundled handler; since Channels 4.0 you must route it yourself, normally toget_asgi_application(). Without the key, HTTP requests are refused withValueError: No application configured for scope type 'http'. -
as_asgi()is the consumer’sas_view(). A consumer class is not itself an ASGI callable you can route to.Consumer.as_asgi(*initkwargs)returns one that builds a *new consumer instance for every scope (every connection), passinginitkwargsto the constructor, exactly likeView.as_view()does per request. -
The
"websocket"stack is wrapped inside-out: origin check first (so a hostile page never reaches your code), then cookies, session and user, then your own middleware, then URL routing. Origin validation and Authentication and sessions explain each layer.
URLRouter
URLRouter takes a list of django.urls.path() or re_path() entries (not url(), which Django 4.0 removed), matched
against scope["path"] the way the URL dispatcher matches a request (see
URLs & Routing). Captured values arrive in the scope:
# catalog/routing.py
from django.urls import path
from . import consumers
websocket_urlpatterns = [
path("ws/echo/", consumers.EchoConsumer.as_asgi()),
path("ws/books/<int:book_id>/discussion/", consumers.DiscussionConsumer.as_asgi()),
path("ws/notifications/", consumers.NotificationConsumer.as_asgi()),
]
http_urlpatterns = [
path("feed/reviews/", consumers.ReviewFeedConsumer.as_asgi()),
]
Inside the consumer, self.scope["url_route"]["kwargs"]["book_id"] is an int thanks to the <int:book_id> converter;
args holds positional groups from re_path. Notes from the documentation and the source:
-
Named and unnamed regex groups cannot be mixed: positional groups are discarded as soon as a named one matches.
-
include()is not supported: use nestedURLRouterinstances. NestingURLRouter`s under `path()routes while wrapping the inner router in extra middleware is a documented gotcha (Channels issue 1428); put the middleware around the outermost router instead. -
URLRoutermatches the path only, not the query string and not the method. TheHTTProuter inasgi.pylists the long-lived paths first and usesre_path(r"", django_asgi_app)as a match-everything last entry, the pattern the documentation recommends for splitting HTTP between consumers and Django. -
No match is a server error, not a 404. The outermost
URLRouterraisesValueError: No route found for path …; on a WebSocket the handshake fails with HTTP 500 and the traceback goes to the server log (observed with Daphne). Add a catch-all consumer that closes the socket if you want a quiet refusal. -
When the server sets a
root_path(a Django app mounted under a prefix), the outermostURLRouterstrips it before matching (fixed in Channels 4.1).
ProtocolTypeRouter and ChannelNameRouter
ProtocolTypeRouter maps scope["type"] to an application: "http", "websocket" and, for workers, "channel"
(Background workers). A scope type with no entry raises ValueError. ChannelNameRouter is the third router: it dispatches
channel-type scopes by scope["channel"] and exists only for background workers. All three are plain classes you could
write yourself; the documentation recommends a ProtocolTypeRouter as the root, with protocol-specific routing below it.
Routers work on the scope, not on events: you choose one consumer for the whole connection, and cannot spread one connection’s events over several consumers.
Source: ProtocolTypeRouter, URLRouter and ChannelNameRouter
Consumers
A consumer is the unit of application code in Channels: the long-lived cousin of a view. It is a class whose instance serves exactly one connection, from the first event to the last, and whose methods are called when events happen — you never write an event loop. Channels ships two base classes and a family of ready-made WebSocket and HTTP consumers built on them.
The model: events, handler names and the scope
Both base classes (channels.consumer.SyncConsumer and channels.consumer.AsyncConsumer) work the same way. Every event
is a dict with a "type"; Channels replaces each . in the type with _ and calls the method of that name. A
websocket.connect event calls websocket_connect(event), a channel-layer event of type chat.join calls chat_join.
Besides self.send(event) (to the client or server) the instance gets:
| Attribute | Meaning |
|---|---|
|
The connection’s scope dict: |
|
The channel layer for |
|
A unique name for this connection’s mailbox on the layer, generated when the consumer starts. |
The raw flavours are the "hello world" of the model. They also show the part the generic consumers do for you: after
websocket.disconnect the consumer must raise channels.exceptions.StopConsumer, or the server waits for the application
to exit (Daphne’s --application-close-timeout, 10 seconds by default) and then kills it with a warning.
# catalog/lowlevel.py
from channels.consumer import AsyncConsumer, SyncConsumer
from channels.exceptions import StopConsumer
class SyncEcho(SyncConsumer):
"""Raw ASGI events, handled in a thread-pool thread."""
def websocket_connect(self, event):
self.send({"type": "websocket.accept"})
def websocket_receive(self, event):
self.send({"type": "websocket.send", "text": event["text"]})
def websocket_disconnect(self, event):
raise StopConsumer() # tell the server the application is finished
class AsyncEcho(AsyncConsumer):
"""The same thing with coroutines: every handler and self.send is async."""
async def websocket_connect(self, event):
await self.send({"type": "websocket.accept"})
async def websocket_receive(self, event):
await self.send({"type": "websocket.send", "text": event["text"]})
async def websocket_disconnect(self, event):
raise StopConsumer()
Source: Consumers: basic layout and Closing consumers
A handler name with no method is an error, not a silent ignore: a typo in a group event type ("chat.mesage") raises
ValueError: No handler for message type chat.mesage inside the consumer, which ends the connection. The same applies to
an event type that starts with an underscore (ValueError: Malformed type in message (leading underscore)). Prefix your
own types (discussion., notify) so they cannot collide with the protocol’s (websocket., http.).
Sync or async?
The two classes differ in where your code runs:
-
AsyncConsumer: every handler is a coroutine and runs on the event loop. A blocking call (a synchronous ORM query,requests.get(),time.sleep()) stalls every connection served by that process. Inside, useawaitand async-native libraries, the async ORM (aget(),acreate(),aexists(),async for) ordatabase_sync_to_async(Database access from consumers). -
SyncConsumer: Channels runs the whole handler in a thread-pool thread (usingdatabase_sync_to_async, so database connections are closed afterwards) and bridgesself.sendback to the loop. You may call the ORM and any blocking code freely; the cost is one thread per concurrently running handler, and one database connection per thread.
The Channels documentation still recommends sync consumers by default and async ones where you know async will help. The async ORM (Django 4.1, extended in 4.2; the Channels documentation reflects it since 4.2.0) makes the async side far more practical than when that advice was written. A workable rule: use async for connections that mostly wait and forward (chat, feeds, presence, SSE), and sync for handlers that are mainly ORM-heavy business logic or call blocking libraries. Never mix: all handlers of one consumer must be the same kind, including channel-layer event handlers.
The general async/sync boundary in Django (sync_to_async, async_to_sync, SynchronousOnlyOperation) is covered in
Async Support and, for the language side, in
Concurrency and Async.
Generic WebSocket consumers
Like Django’s generic views, Channels ships consumers that wrap the verbose ASGI events. All live in
channels.generic.websocket:
| Class | What it adds |
|---|---|
|
Synchronous. |
|
The same methods, all |
|
Synchronous. |
|
Async variant; note that |
The pieces you will use constantly:
-
connect()runs when the browser asks to open the socket. The default accepts every connection (soEchoConsumer.connectbelow is only there to be explicit). Callself.accept()to accept,self.accept("subprotocol")to choose one of thescope["subprotocols"]the client offered,self.accept(headers=[…])(Channels 4.1) to add response headers, orself.close()to refuse. You can raisechannels.exceptions.AcceptConnectionorDenyConnectionfrom anywhere insideconnect(reusable authentication or rate-limit helpers) instead of calling them. -
receive()gets eithertext_dataorbytes_dataper frame, never both; the JSON consumers only accept text and raiseValueErroron a binary frame (and on an empty text frame, because the check isif text_data:). -
send(text_data=None, bytes_data=None, close=False)sends one frame; it raisesValueErrorunless you pass exactly one kind.close=True(or a code) closes after sending.self.close(code=None, reason=None)closes from the server. -
disconnect(code)runs when the socket closes for any reason. It may not run on a crash, a power loss or a killed process, so do not rely on it for correctness; the layer’sgroup_expiryis the safety net for group membership. -
groups, a class attribute: names of groups to join on connect and leave on disconnect automatically (on both the sync and async consumers). It is static, so it suits "everyone gets this" streams; per-user or per-book groups are managed by hand, as below.
# catalog/consumers.py (excerpt)
class EchoConsumer(WebsocketConsumer):
"""Smallest useful consumer: accept, echo text back."""
def connect(self):
self.accept()
def receive(self, text_data=None, bytes_data=None):
if text_data is not None:
self.send(text_data=text_data)
def disconnect(self, code):
pass
Source: WebsocketConsumer and JsonWebsocketConsumer
An async JSON consumer: the discussion room
Now a real one. Readers of a book join a room; every message is validated, saved with the async ORM and broadcast to everyone in the room. The room is a group named after the book, so the consumer needs no knowledge of the other connections.
# catalog/consumers.py (excerpt)
class DiscussionConsumer(AsyncJsonWebsocketConsumer):
"""One group per book; everyone on the page sees every message."""
MAX_LENGTH = 500
async def connect(self):
self.book_id = self.scope["url_route"]["kwargs"]["book_id"]
self.user = self.scope["user"]
self.group_name = f"book-{self.book_id}"
if not self.user.is_authenticated:
await self.close(code=4401) # application-defined: "log in first"
return
if not await Book.objects.filter(pk=self.book_id).aexists():
await self.close(code=4404)
return
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()
async def disconnect(self, code):
# connect() may have closed before joining; group_discard is harmless then
if hasattr(self, "group_name"):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
async def receive_json(self, content, **kwargs):
text = str(content.get("text", "")).strip()
if not text or len(text) > self.MAX_LENGTH:
await self.send_json({"error": "text must be 1-500 characters"})
return
message = await DiscussionMessage.objects.acreate(
book_id=self.book_id, author=self.user, text=text
)
await self.channel_layer.group_send(
self.group_name,
{
"type": "discussion.message", # -> discussion_message()
"id": message.pk,
"author": self.user.get_username(),
"text": text,
},
)
async def discussion_message(self, event):
await self.send_json(
{"id": event["id"], "author": event["author"], "text": event["text"]}
)
Walk through the choices, because each one answers a pitfall:
-
connectreads the book id fromscope["url_route"]and the user fromscope["user"](Authentication and sessions). Unauthenticated users and unknown books are refused beforegroup_add, so nothing leaks to a stranger.disconnectstill runs whenconnectrefused, hence thehasattrguard. -
The message
"type": "discussion.message"is the event sent through the layer: Channels will calldiscussion_message(self, event)on every consumer in the group, including the sender. That last method turns the event into the frame the browser sees. What travels on the layer and what travels on the socket are two separate formats, and keeping them separate lets you change one without the other. -
receive_jsonvalidates the payload itself. The browser can send anything, so check types and sizes, answer mistakes on the same socket, and never trust a user id or a book id inside the payload when the scope already tells you. -
The database write uses
DiscussionMessage.objects.acreate(…): a coroutine, safe on the event loop. The event carries the saved row’s id, so every client can de-duplicate. -
Closing before accepting makes the server answer the upgrade with HTTP 403 (the handshake is refused). The numeric code (4401 and 4404 here are application-defined; RFC 6455 reserves 4000-4999 for private use) is visible to the test communicator but not to a browser, which sees only a failed connection. If the client must tell "log in" from "not found", accept first and close afterwards with the code, which does arrive in the browser’s
CloseEvent.
A browser client for it. Note textContent: inserting received text with innerHTML would turn a chat message into a
stored cross-site-scripting hole (see Security).
// catalog/static/catalog/discussion.js
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(
`${scheme}://${window.location.host}/ws/books/1/discussion/`,
);
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const item = document.createElement("li");
item.textContent = `${message.author}: ${message.text}`; // never innerHTML
document.querySelector("#messages").append(item);
});
socket.addEventListener("close", (event) => {
console.log("socket closed", event.code);
});
document.querySelector("#send").addEventListener("click", () => {
const input = document.querySelector("#text");
socket.send(JSON.stringify({ text: input.value }));
input.value = "";
});
The same WebSocket calls (open, send, message, close) were run with Node 24’s built-in client against Daphne for this
page, with the signed ticket of Clients that cannot send cookies standing in for the session cookie a browser would send; the DOM lines were
not run in a browser. A production client also reconnects with a growing delay when close fires with an unexpected
code, and re-fetches missed messages from the database over HTTP, because the layer gives no replay (Delivery guarantees and limits).
Source: AsyncJsonWebsocketConsumer and MDN: WebSocket
A sync JSON consumer: notifications
The synchronous twin. This one only pushes: each user has a private group, and anything that sends to
user-<id> reaches every browser tab that user has open. The layer is async-only, so a sync consumer wraps its calls in
async_to_sync.
# catalog/consumers.py (excerpt)
class NotificationConsumer(JsonWebsocketConsumer):
"""Synchronous variant: one group per user, server-to-client pushes only."""
def connect(self):
user = self.scope["user"]
if not user.is_authenticated:
self.close(code=4401)
return
self.group_name = f"user-{user.pk}"
# the channel layer is async-only: bridge with async_to_sync
async_to_sync(self.channel_layer.group_add)(self.group_name, self.channel_name)
self.accept()
def disconnect(self, code):
if hasattr(self, "group_name"):
async_to_sync(self.channel_layer.group_discard)(
self.group_name, self.channel_name
)
def notify(self, event):
self.send_json({"kind": event["kind"], "detail": event["detail"]})
The event type "notify" maps to the method notify, which is why no _ appears. Sending to the group is shown in
Sending from views, signals and tasks; the test is in Testing consumers.
HTTP consumers and server-sent events
channels.generic.http.AsyncHttpConsumer turns an HTTP request into a coroutine you control. Implement
handle(self, body) (the whole request body as bytes), then answer with send_response(status, body, headers=…) or, for
streaming, send_headers(status=200, headers=…) followed by send_body(bytes, more_body=True) calls and a final
send_body(bytes). Headers may be a list of byte pairs or a dict, and the server only starts sending after the first
send_body. Override disconnect() for cleanup; it also runs on an unclean disconnect.
A server-sent-events feed has to stay open and react to events from the channel layer, which needs one non-obvious
step. AsyncHttpConsumer.http_request calls handle() and then raises StopConsumer as soon as handle() returns,
ending the consumer. Group events are dispatched one at a time, after the current handler returns, so a feed written
as a handle() that sends headers and returns never receives its group events (reproduced with Channels 4.3.2: the
consumer was already finished a moment after the first body chunk). The long-poll example in the Channels documentation
has that shape; do not copy it for a stream fed by the layer. Override http_request instead and return from it without
stopping:
# catalog/consumers.py (excerpt)
class ReviewFeedConsumer(AsyncHttpConsumer):
"""Server-sent events: an HTTP response that stays open and is fed by group events.
AsyncHttpConsumer.http_request() raises StopConsumer as soon as handle()
returns, which would end the consumer before any group event arrives. An
event-driven stream therefore overrides http_request() and returns from it
without stopping; the consumer then lives until http_disconnect().
"""
GROUP = "reviews"
async def http_request(self, message):
if message.get("more_body"):
return # a GET has no body; ignore any further chunks
await self.channel_layer.group_add(self.GROUP, self.channel_name)
await self.send_headers(
headers=[
(b"Cache-Control", b"no-cache"),
(b"Content-Type", b"text/event-stream"),
]
)
# Headers only go out with the first body event; more_body keeps it open.
await self.send_body(b": connected\n\n", more_body=True)
async def review_posted(self, event):
payload = json.dumps({"book": event["book"], "stars": event["stars"]})
await self.send_body(f"data: {payload}\n\n".encode(), more_body=True)
async def http_disconnect(self, message):
await self.channel_layer.group_discard(self.GROUP, self.channel_name)
await super().http_disconnect(message)
Run under Daphne, with a POST to /reviews/ (Sending from views, signals and tasks) made while the stream is open:
curl -N http://localhost:8760/feed/reviews/
: connected
data: {"book": "Dune", "stars": 5}
Browsers consume it with new EventSource("/feed/reviews/"); the line starting with a colon is an SSE comment, a cheap
way to flush headers early. Compared with Django’s own streaming responses
(Responses: Streaming, CSV and PDF), a Channels HTTP consumer
can be fed by other processes through the layer; if your stream only reads from a database or a generator, an async
view with StreamingHttpResponse (async iterators are supported from Django 4.2) needs no layer. Both patterns are
compared in Async Support.
The while True: … await asyncio.sleep(1) server-sent-events sample in the Channels documentation keeps
handle() running forever; by the dispatch logic in the 4.3.2 source (read, not run) no other event is dispatched to
the consumer while handle() runs, so it suits a pure timer, not a fan-out feed.
Source: AsyncHttpConsumer
Channel layers and groups
Consumers handle their own connection. To reach another connection — the other readers in a room, one user’s other tabs, a browser whose consumer lives in a different process — they send events through a channel layer: a small message bus with named mailboxes. A layer offers two addressing modes:
-
a channel: one mailbox. Every consumer creates one when it starts (its
channel_name); sending to it reaches that single connection. The name is only valid for the lifetime of the connection. -
a group: a named broadcast list of channels.
group_add(group, channel)subscribes a channel,group_discardremoves it,group_send(group, event)delivers a copy of the event to every member. A group can neither be listed nor counted: it is a pure broadcast. If you need "who is in the room", keep that data yourself.
The interface is async-only (await layer.send(…)). Synchronous code wraps each call in async_to_sync. A layer is
optional: leave CHANNEL_LAYERS unset and consumers simply have no channel_layer.
Choosing and configuring a layer
Channels bundles channels.layers.InMemoryChannelLayer; the maintained production layers are in the channels_redis
package (the documentation calls it the only official, Django-maintained layer supported for production).
| Backend | Use it for |
|---|---|
|
Tests and a single-process development server. Each process has its own layer, so nothing crosses processes: with two Daphne workers, two browsers can sit in the same "room" and never see each other. Do not use it in production. |
|
Production. The original layer: channels and groups implemented over Redis lists, sorted sets and a Lua script; supports sharding over several Redis hosts, message expiry, capacities and optional encryption. |
|
Production, newer, built on Redis Pub/Sub. Its README labels it Beta, meaning it may change incompatibly. It can drop
messages while the connection to Redis is partitioned; it offers |
The settings below use Redis when REDIS_URL is set and the in-memory layer otherwise, so development and tests need no
server and production gets its address from the environment (never from the repository):
# config/settings.py (excerpt)
import os
REDIS_URL = os.environ.get("REDIS_URL") # e.g. redis://redis.internal:6379/0
if REDIS_URL:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [REDIS_URL],
"prefix": "bookshelf",
"capacity": 200,
},
},
}
else:
CHANNEL_LAYERS = {
"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}
This configuration was not run against a Redis server (none was available). Instantiating
RedisChannelLayer(hosts=["redis://redis.internal:6379/0"], prefix="bookshelf", …) with every option below was done
without connecting, to confirm the names are accepted by channels_redis 4.3.0, and the layer was never asked to send.
The CONFIG options, from the channels_redis README (main branch, checked 2026-10-03) and the 4.3.0 source:
| Option | Default | Meaning |
|---|---|---|
|
|
A list of Redis URLs (or dicts accepted by redis-py’s async client; Sentinel needs dicts with |
|
|
Prefix of every Redis key. Give each independent layer sharing a Redis server its own prefix; every server in the same layer must use the same one. |
|
|
Seconds an undelivered message stays queued. Lower it to drop peaks, raise it to backlog them. |
|
|
Seconds a channel stays in a group after |
|
|
Messages a channel can hold before it refuses more. |
|
none |
Per-channel capacities by name pattern (a dict of glob strings or compiled regexes). |
|
none |
A list of keys; the first encrypts, all decrypt, so keys can rotate without downtime. Needs the |
|
|
|
Redis server 5.0 or newer is required. Route the connection over TLS (rediss://) when it leaves a private network:
encryption of the payload does not hide channel and group names from someone who can read the traffic.
The API in four calls
| Call | Meaning |
|---|---|
|
The configured layer (from |
|
Deliver |
|
Subscribe / unsubscribe a channel to a group. |
|
Deliver |
|
Used by consumers and tests: create a channel name, wait for its next event, empty the layer. |
Names are validated: channels and groups accept ASCII letters, digits, hyphen, underscore and period only (the
documentation lists hyphens and periods; the source also allows underscores), fewer than 100 characters. "book:1",
"new books" and a 100-character name raise TypeError, which is why the groups above are book-1 and user-7. Channels 4.2.1
also warns about long group names.
What you send should be a high-level event, not a frame: {"type": "discussion.message", "id": …, "text": …},
which each consumer turns into its own socket output. Ids and small values beat whole serialised model instances: the
receiving consumer can check permissions and load fresh data.
# catalog/tests/test_layers.py
from channels.exceptions import ChannelFull
from channels.layers import get_channel_layer
from .base import LayerTestCase
class ChannelLayerTests(LayerTestCase):
async def test_point_to_point_and_groups(self):
layer = get_channel_layer()
a, b = await layer.new_channel(), await layer.new_channel()
await layer.send(a, {"type": "ping", "n": 1})
self.assertEqual(await layer.receive(a), {"type": "ping", "n": 1})
await layer.group_add("room", a)
await layer.group_add("room", b)
await layer.group_send("room", {"type": "hello"})
self.assertEqual((await layer.receive(a))["type"], "hello")
self.assertEqual((await layer.receive(b))["type"], "hello")
await layer.group_discard("room", b)
await layer.group_send("room", {"type": "again"})
self.assertEqual((await layer.receive(a))["type"], "again")
async def test_group_names_are_validated(self):
layer = get_channel_layer()
with self.assertRaises(TypeError):
await layer.group_add("not a valid name!", "specific.abc")
with self.assertRaises(TypeError):
await layer.group_add("x" * 100, "specific.abc")
async def test_full_channels_refuse_messages(self):
layer = get_channel_layer()
channel = await layer.new_channel()
for n in range(layer.capacity): # the default capacity is 100
await layer.send(channel, {"type": "fill", "n": n})
with self.assertRaises(ChannelFull):
await layer.send(channel, {"type": "one.too.many"})
Sending from views, signals and tasks
The same layer is reachable from any code in the same project: a view, a signal receiver, a management command, a Celery
or django.tasks job. Fetch it with get_channel_layer() and call its async methods: with async_to_sync from
synchronous code, and with await from an async view or task. Both flavours, publishing to the groups the consumers above
joined:
# catalog/views.py
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .middleware import issue_ticket
@login_required
@require_POST
def websocket_ticket(request):
return JsonResponse({"ticket": issue_ticket(request.user)})
@login_required
@require_POST
def post_review(request):
"""A classic synchronous view that pushes an event to every open feed."""
layer = get_channel_layer()
async_to_sync(layer.group_send)(
"reviews",
{
"type": "review.posted",
"book": request.POST["book"],
"stars": int(request.POST["stars"]),
},
)
return JsonResponse({"queued": True}, status=202)
@login_required
@require_POST
async def announce(request):
"""The same push from an async view: just await the layer."""
layer = get_channel_layer()
await layer.group_send(
f"book-{request.POST['book_id']}",
{
"type": "discussion.message",
"id": 0,
"author": "librarian",
"text": request.POST["text"],
},
)
return JsonResponse({"queued": True}, status=202)
# config/urls.py
from django.contrib import admin
from django.urls import path
from catalog import views
urlpatterns = [
path("admin/", admin.site.urls),
path("ws-ticket/", views.websocket_ticket, name="ws-ticket"),
path("reviews/", views.post_review, name="post-review"),
path("announce/", views.announce, name="announce"),
]
Notes:
-
The group event types (
review.posted,discussion.message) are exactly the ones the consumers handle; a view must know the same contract as the consumer, so keep these names in one module. -
Authenticate and authorise the view as you would any other: this code writes into every client’s socket. The examples use
@login_requiredand@require_POST(these views are state-changing, so POST and CSRF apply as usual). -
When the event describes a database change, send it after commit with
transaction.on_commit(…), for the same reason as with background tasks: a consumer that reacts by reading the row must see it (Tasks Framework and Transactions, Raw SQL and Multiple Databases). -
A cheap way to decouple the sender from Channels is a signal receiver or a model method that calls one small
publish(group, event)helper; tests then patch that single function. -
In the test suite the view test is
test_view_can_push_into_a_groupin Testing consumers, and the SSE one istest_server_sent_events_stream_stays_open.
Source: Using outside of consumers and Django: async views
Delivery guarantees and limits
A channel layer is a low-latency, best-effort bus, not a queue with promises.
-
At most once. A message is delivered to a channel at most once; if the consumer is gone or too slow it is lost. There is no acknowledgement, retry or dead-letter. Anything that must not be lost belongs in the database first, with the event only a hint to refresh (that is why
DiscussionConsumersaves the message and then broadcasts it). -
Messages expire after
expiryseconds (60 by default), and a channel holds at mostcapacitymessages (100). A full channel makessendraiseChannelFull, andgroup_sendskips the receiver. -
No history. A client that reconnects sees nothing from before. Give clients a way to re-sync: an HTTP endpoint for the latest messages, or an id they send on reconnect.
-
Serialisation differs between layers. The in-memory layer deep-copies the dict, so almost anything passes; the Redis layers serialise with msgpack (or JSON) and tuples come back as lists and a
datetimeraisesTypeError(both checked withchannels_redis.serializers). Tests on the in-memory layer therefore cannot prove an event is Redis-safe: send only strings, numbers, booleans,None, lists and dicts of those. -
Group membership expires after
group_expiryfrom the moment ofgroup_add(see the options table), anddisconnect()may never run: do not treat a group as proof that a client is connected. -
Redis is a single point of failure unless you run Sentinel or a managed service; while it is unreachable, layer calls fail and the errors surface in your consumers and views. For Redis operations see Redis replication, Sentinel and Cluster and Redis security; for the Pub/Sub primitive underneath the Pub/Sub layer see Redis streams and Pub/Sub.
Background workers
The same layer can carry work to worker processes. Instead of a protocol server, manage.py runworker listens on
named channels and feeds each message to a consumer routed with ChannelNameRouter. The consumer below handles events of
type "cover.resize"; the type becomes the method name:
# catalog/workers.py
from channels.consumer import SyncConsumer
PROCESSED = [] # stand-in for real work such as resizing a cover image
class CoverConsumer(SyncConsumer):
"""Receives events of type "cover.resize" sent to the "covers" channel."""
def cover_resize(self, event):
PROCESSED.append(event["book_id"])
# config/asgi.py (excerpt): next to the "http" and "websocket" entries
from channels.routing import ChannelNameRouter
from catalog.workers import CoverConsumer
application = ProtocolTypeRouter(
{
# ... "http" and "websocket" as before ...
"channel": ChannelNameRouter({"covers": CoverConsumer.as_asgi()}),
}
)
# anywhere that can reach the layer
await get_channel_layer().send("covers", {"type": "cover.resize", "book_id": 7})
python manage.py runworker covers
The consumer and the ChannelNameRouter were tested with ApplicationCommunicator (see test_lowlevel in Testing consumers);
runworker itself was not run, because the documentation states that background tasks do not work with the in-memory
layer and no Redis was available. The documentation is explicit about the trade-off: no retries, no return values,
at-most-once delivery. For anything that needs those guarantees use a task queue, i.e. the built-in
Tasks Framework (for the simple cases) or
Celery Integration.
Source: Worker and background tasks
Authentication and sessions
A WebSocket starts life as an HTTP GET with an Upgrade header, so the browser sends the same cookies it sends to your
views, including the session cookie. Channels reuses Django’s session and authentication machinery on that handshake;
it does not run Django’s MIDDLEWARE (that list belongs to the HTTP handler behind get_asgi_application()), so
anything a WebSocket consumer needs must come from ASGI middleware wrapped around the router.
AuthMiddlewareStack
AuthMiddlewareStack(inner) is a shortcut for three middlewares, outermost first:
| Middleware | Puts into the scope |
|---|---|
|
|
|
|
|
|
scope["user"] is a UserLazyObject, but AuthMiddleware resolves it (through database_sync_to_async, in a
thread) before your consumer is called, so reading self.scope["user"] in an async consumer does not touch the
database on the event loop. The user is read-only: Channels never writes it back. In the discussion consumer
(Consumers) connect() refuses anonymous users with close code 4401 and the tests check both cases.
Two details catch people out:
-
Sessions are not saved automatically in WebSocket consumers. HTTP consumers save a modified session when they send the response; a WebSocket consumer must call
scope["session"].save()(orawait scope["session"].asave()in async code) itself, or other connections and views never see the change. -
channels.auth.login(scope, user)andlogout(scope)exist (async functions; wrap them inasync_to_syncfrom a sync consumer), and need the same explicitsave(). Most sites never need them: users log in through the normal HTTP login view (see Authentication) and the socket simply inherits that session.
Source: Authentication and Sessions
The full stack was exercised against the real config/asgi.py: a session created by Django’s test client
(aforce_login()) is sent as a cookie header, and the consumer sees alice. The same test file is shown in full in
Testing consumers; its first test is the important part here:
# catalog/tests/test_stack.py (excerpt)
async def test_session_cookie_authenticates_the_socket(self):
await self.async_client.aforce_login(self.alice)
session_id = self.async_client.cookies[settings.SESSION_COOKIE_NAME].value
cookie = f"{settings.SESSION_COOKIE_NAME}={session_id}".encode()
communicator = WebsocketCommunicator(
application, self.path(), headers=[ORIGIN, (b"cookie", cookie)]
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to({"text": "from a cookie"})
self.assertEqual((await communicator.receive_json_from())["author"], "alice")
await communicator.disconnect()
Source: WebsocketCommunicator and Client.force_login()
Long-lived connections outlive logouts
The scope is built once, at the handshake. A socket opened at 09:00 still carries the 09:00 session at 17:00, even if
the user clicked "log out" in another tab at noon. The documentation suggests calling channels.auth.get_user(scope)
periodically, and it does re-read the user row (so a deactivated user or a changed password is noticed), but it checks
the session object already loaded into the scope: in the scratch project it still returned alice after the session
had been deleted by a logout. To notice a logout, read the session again from the store before privileged actions:
# catalog/auth_checks.py
from importlib import import_module
from channels.auth import get_user
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from django.conf import settings
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
async def fresh_user(scope):
"""Re-read the session from the store: was the user logged out elsewhere?
scope["session"] was loaded once, when the socket opened; get_user(scope)
re-checks the user row and the password hash, but not a session that a
logout in another tab has since deleted.
"""
store = SessionStore(scope["session"].session_key)
return await get_user({"session": store})
class WhoAmIConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
if self.scope["user"].is_authenticated:
await self.accept()
else:
await self.close(code=4401)
async def receive_json(self, content, **kwargs):
cached = await get_user(self.scope)
current = await fresh_user(self.scope)
if not current.is_authenticated:
await self.close(code=4401) # session gone: stop serving this socket
return
await self.send_json(
{"cached": cached.get_username(), "current": current.get_username()}
)
# catalog/tests/test_auth_checks.py (excerpt)
await self.async_client.alogout() # e.g. "log out" clicked in another tab
await communicator.send_json_to({})
self.assertEqual((await communicator.receive_output())["type"], "websocket.close")
How often to re-check is a trade-off (each re-check is a session read); "before every write" is a reasonable default for a chat, "on every message" for anything that moves money. The same thought applies to permissions: authorise each action, not just the connection (see Permissions & Authorization).
Clients that cannot send cookies
A browser on your own domain sends the session cookie automatically; nothing else is needed. A mobile app or a script
usually has an API token instead, and the browser WebSocket API cannot set an Authorization header. Marani’s book
solves this by putting the long-lived API token in the URL query string and looking it up with a synchronous ORM call
inside the async middleware; both are mistakes. URLs end up in proxy and server access logs, browser history and
monitoring tools, so a token placed there is a leaked credential, and a blocking query in async middleware stalls the
event loop for every connection on that process.
The scratch project uses a narrower pattern instead: an authenticated HTTP endpoint hands out a ticket — a value
signed with Django’s signing module, salted for this one purpose and valid for 30 seconds, i.e. only long enough to
open the socket — and a small middleware turns it into scope["user"]:
# catalog/middleware.py
from channels.db import database_sync_to_async
from channels.middleware import BaseMiddleware
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from django.core import signing
from urllib.parse import parse_qs
TICKET_SALT = "catalog.ws-ticket"
TICKET_MAX_AGE = 30 # seconds: a ticket is only needed to open the socket
def issue_ticket(user):
"""Called from a normal HTTP view for a logged-in user."""
return signing.dumps({"uid": user.pk}, salt=TICKET_SALT)
@database_sync_to_async
def _user_for_ticket(ticket):
try:
data = signing.loads(ticket, salt=TICKET_SALT, max_age=TICKET_MAX_AGE)
except signing.BadSignature: # includes SignatureExpired
return AnonymousUser()
User = get_user_model()
return User.objects.filter(pk=data["uid"], is_active=True).first() or AnonymousUser()
class TicketAuthMiddleware(BaseMiddleware):
"""Authenticate clients that cannot send cookies (mobile apps, scripts)."""
async def __call__(self, scope, receive, send):
scope = dict(scope) # never mutate the caller's scope
user = scope.get("user")
if user is None or not user.is_authenticated:
query = parse_qs(scope.get("query_string", b"").decode())
ticket = (query.get("ticket") or [""])[0]
if ticket:
scope["user"] = await _user_for_ticket(ticket)
return await super().__call__(scope, receive, send)
What makes it safe enough:
-
Not the credential itself. The ticket cannot call the API, cannot be refreshed, expires after
TICKET_MAX_AGEseconds and is bound to its salt, so a ticket copied from a log afterwards is useless. If even a 30-second replay window is too much, store a random nonce in the cache when issuing and delete it on first use. -
No blocking call on the event loop.
signing.loads()is CPU-only, and the user lookup is adatabase_sync_to_asyncfunction (Database access from consumers). -
query_stringis bytes. The ASGI scope carries the raw query string asbytes; it is decoded beforeparse_qs. Comparing it, or values sliced from it, withstrnever matches (the book’s bug), and the short illustrative middleware in the Channels documentation, which passes the whole raw query string toint(), only works when the query string is nothing but a number. -
A fresh scope.
scope = dict(scope)copies before writing, the same thingAuthMiddlewaredoes, so the outer middleware’s dictionary is never mutated. -
Placement. It runs inside
AuthMiddlewareStackand only fills in a user when the cookie produced none. Forged and expired tickets give anAnonymousUser, and the consumer’s own check closes with4401.
BaseMiddleware is the Channels 3+ middleware shape: a single async callable call(scope, receive, send). The
Channels 2 two-step "instance per scope, then `call`" style no longer works (Migrating from Channels 2 to Channels 4).
Origin validation
Cookies make WebSockets vulnerable to cross-site WebSocket hijacking: any page on the internet can run
new WebSocket("wss://bookshelf.example/ws/…"), and the browser attaches your user’s cookies. Django’s CSRF
protection does not apply — it is HTTP middleware, and the handshake carries no CSRF token. What the browser does send,
and a hostile page cannot forge, is the Origin header. Channels checks it with two ASGI middlewares from
channels.security.websocket:
| Middleware | Allows |
|---|---|
|
Origins matching the list: a bare domain ( |
|
|
Rejected connections are closed before accept(), which protocol servers turn into an HTTP 403 on the handshake.
Against Daphne in the scratch project, a foreign Origin, a missing Origin and an anonymous user were each refused with
HTTP 403 (WSREJECT in Daphne’s log), while the same page with Origin: http://localhost:8760 connected.
Rules that follow from how the validator works:
-
Wrap outermost. Put the validator around
AuthMiddlewareStack, asconfig/asgi.pydoes, so a hostile page is turned away before a session is even loaded. -
A missing
Originis refused unless"*"is allowed. Browsers always send one; scripts and mobile clients may have to set it explicitly (the Node and Python clients used for this page did). -
ALLOWED_HOSTSis read when the stack is built, i.e. whenasgi.pyis imported, not per connection. A test that overrides the setting must build its own stack (test_validator_reads_allowed_hosts_when_it_is_builtin Testing consumers). -
It is not authorisation. Origin checks stop other sites' pages; they do nothing against a malicious client that sets any
Originit likes. Authenticate inconnect()and authorise every message.
Source: Security and ALLOWED_HOSTS
Database access from consumers
The ORM’s query execution is synchronous. What that means for a consumer depends on its flavour:
| Consumer | How to query |
|---|---|
Sync ( |
Call the ORM normally. Every handler already runs in a worker thread, and Channels closes stale connections around each one, as Django does around a request. |
Async ( |
Use the async ORM ( |
The discussion consumer uses the async ORM directly (aexists() and acreate()). Anything more involved — a
values() query with slicing and joins, code shared with sync views, a transaction — is clearer as a plain sync function
run by database_sync_to_async:
# catalog/queries.py
from channels.db import aclose_old_connections, database_sync_to_async
from .models import DiscussionMessage
@database_sync_to_async
def recent_messages(book_id, limit=20):
"""Plain synchronous ORM code, run in a thread and cleaned up afterwards."""
rows = (
DiscussionMessage.objects.filter(book_id=book_id)
.order_by("-created")
.values("id", "author__username", "text")[:limit]
)
return list(reversed(rows)) # evaluate inside the thread, oldest first
async def history_for(book_id):
"""Call from a long-lived async consumer that may have been idle for a while."""
await aclose_old_connections() # drop connections the database may have closed
return await recent_messages(book_id)
Source: database_sync_to_async and aclose_old_connections
test_queries calls history_for() from an async def test and gets both rows back, oldest first. The points worth
remembering:
-
database_sync_to_asyncisasgiref.sync.sync_to_asyncplus connection cleanup. Before and after the wrapped function it closes connections that are pastCONN_MAX_AGEor broken, so a thread never reuses a dead connection. Plainsync_to_asyncdoes not; prefer the Channels version for ORM code inside consumers. -
Evaluate inside the thread. A
QuerySetis lazy: returning it unevaluated from the wrapped function moves the actual query back to the event loop, where it fails.list(…),.first(),.count()or avalues()slice turned into a list keep the query in the thread. -
Long-lived async consumers need
aclose_old_connections(). Since Channels 4.2 async consumers close old connections when a connection starts, when it ends and whenever a message arrives from the client. Group events from the channel layer and timers do not trigger this, so a consumer woken bygroup_sendafter an hour of silence may hold a connection the database server already dropped. Callawait aclose_old_connections()before the first query in a while, ashistory_for()does. -
Count the threads. Sync consumers and
database_sync_to_asyncshare one thread pool; each thread can hold a database connection. The pool size followsmin(32, os.cpu_count() + 4)and can be capped with theASGI_THREADSenvironment variable. Many open WebSockets on sync consumers can exhaust PostgreSQL’smax_connections; async consumers only borrow a thread for the query. -
Transactions stay synchronous.
transaction.atomic()cannot spanawaitcalls; put the whole transaction in onedatabase_sync_to_asyncfunction (see Transactions, Raw SQL and Multiple Databases).
Marani’s consumers call the synchronous ORM straight from async def handlers. On Channels 2 and Django 2.x nothing
stopped that; it blocked the event loop for every connection on the server. Since Django 3.0 the same line raises
SynchronousOnlyOperation, and since Django 4.1 the async ORM makes most wrappers unnecessary. The wider picture — async
views, sync_to_async, thread_sensitive, async-safe and async-unsafe APIs — is in
Async Support.
Source: Database access and Django: async safety
Running it: Daphne in development and production
Development
With "daphne" first in INSTALLED_APPS and ASGI_APPLICATION set (Installing and configuring Channels), manage.py runserver is Daphne’s
ASGI development server. The scratch project printed:
$ python manage.py runserver 127.0.0.1:8761
System check identified no issues (0 silenced).
October 03, 2026 - 02:03:11
Django version 6.1.1, using settings 'config.settings'
Starting ASGI/Daphne version 4.2.3 development server at http://127.0.0.1:8761/
Quit the server with CONTROL-C.
The line Starting ASGI/Daphne is the quick test: if it says Starting development server instead, Django’s own WSGI
runserver is running, daphne is missing from INSTALLED_APPS (or sits after another app that overrides
runserver), and WebSocket handshakes reach Django’s URLconf as ordinary GET requests and fail. Daphne’s
runserver still serves static files through Django’s ASGIStaticFilesHandler (disable with --nostatic).
Production with Daphne
In production the daphne command loads the module-level application directly:
daphne -b 0.0.0.0 -p 8000 --proxy-headers config.asgi:application
That exact asgi.py was served by daphne -b 127.0.0.1 -p 8760 config.asgi:application for this page; a Python
websockets client then checked echo, origin refusals, cookie and ticket authentication and group fan-out between two
clients, and Node 24’s built-in WebSocket did the same for one message. An unrouted path (/nope/) answered HTTP 500
with ValueError: No route found for path 'nope/' in the log, so a catch-all route that closes politely is worth adding if
clients may probe random paths.
The options you are most likely to need (all confirmed with daphne --help for 4.2.3):
| Option | Default | Purpose |
|---|---|---|
|
|
TCP address. |
|
off |
Use |
|
empty |
Mount the application under a URL prefix, as |
|
|
Keepalive pings on idle WebSockets, and how long to wait for the pong before closing. Keeps connections alive through proxies that drop idle TCP connections. |
|
|
Maximum lifetime of one WebSocket ( |
|
1 MiB each |
Reject oversized messages before they reach your consumer. |
|
|
Grace period for a consumer to finish after the client disconnects before Daphne kills it. |
|
none |
Close HTTP requests (including long-lived HTTP consumers) that take longer than this. |
|
stdout when verbosity >= 1, |
Access log file ( |
Source: Deploying: run protocol servers and Daphne README
A typical deployment puts a reverse proxy in front. It must forward the upgrade headers, or every WebSocket fails at the handshake while normal pages keep working. The Channels documentation’s Nginx example contains these lines:
location @proxy_to_app {
proxy_pass http://channels-backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_redirect off;
proxy_set_header Host $host;
}
The same page runs several Daphne processes from Supervisor with --fd 0 on one shared socket (numprocs roughly the
number of CPUs). Each process holds many connections, but each process also has its own in-memory state: with more than
one process the channel layer must be channels_redis ([redis-config]), or a message sent by a client on process A
never reaches group members on process B. The proxy config was not run for this page (no Nginx in the scratch
environment). For the rest of the deployment checklist — DEBUG = False, ALLOWED_HOSTS, SECURE_* settings, TLS
termination and wss:// — see Security.
Other ASGI servers
Daphne is the reference server but not a requirement since Channels 4 (Installing and configuring Channels). Any server that implements the ASGI
HTTP and WebSocket specification can serve the same application. Django’s ASGI deployment guide covers Daphne,
Hypercorn, Uvicorn and Granian. One echo message through the same asgi.py under Uvicorn, for example:
$ python -m pip install 'uvicorn[standard]' # [standard] adds a WebSocket implementation
$ uvicorn config.asgi:application --host 127.0.0.1 --port 8762
INFO: ASGI 'lifespan' protocol appears unsupported.
INFO: 127.0.0.1:52359 - "WebSocket /ws/echo/" [accepted]
The lifespan line is expected: ProtocolTypeRouter has no "lifespan" key, Uvicorn notices and carries on (pass
--lifespan off to silence it). Without a WebSocket library installed, Uvicorn logs Unsupported upgrade request. and
never calls your consumer. Keep "daphne" in INSTALLED_APPS even then if you want the ASGI
runserver in development; the daphne.E001 ordering check applies either way. ChannelsLiveServerTestCase also still
needs Daphne.
Testing consumers
channels.testing runs an ASGI application in-process, without a server or a socket, and lets a test play the protocol
server’s part. Write the tests as async def methods on Django’s own TestCase (supported since Django 3.1) or, with
pytest, as @pytest.mark.asyncio functions with pytest-django and pytest-asyncio.
| Helper | Use it for |
|---|---|
|
WebSocket consumers and full stacks. |
|
One request/response round trip: |
|
Anything else: a hand-built scope, |
|
Browser tests (Selenium, Playwright) against a real Daphne in a subprocess; needs Daphne installed. |
Source: Testing
Three habits keep the tests fast and isolated:
-
Use
InMemoryChannelLayerin tests (the scratch settings already do; a production project overridesCHANNEL_LAYERSin its test settings). It needs no Redis, but its groups and queues live in the process, so flush it after each test:# catalog/tests/base.py from asgiref.sync import async_to_sync from channels.layers import get_channel_layer from django.test import TestCase class LayerTestCase(TestCase): """Start every test with an empty in-memory channel layer.""" def tearDown(self): async_to_sync(get_channel_layer().flush)() -
Inject the user to unit-test a consumer, drive
asgi.pyto test the stack. A communicator’sscopeis a plain dictionary; settingcommunicator.scope["user"]beforeconnect()skips cookies and sessions, which keeps consumer tests about the consumer. Separate tests run the realapplicationwith real cookies, origins and tickets. -
Assert silence explicitly.
receive_nothing()(default timeout 0.1 s) is how you prove that a message did not reach another group, andreceive_output()raisesasyncio.TimeoutError(after 1 s by default) instead of hanging when something you expected never arrives.
The consumer tests for the Bookshelf discussion room: refusal codes, fan-out to everyone in the group, an invalid payload answered only to its sender, isolation between books, and a view pushing into a group through the layer:
# catalog/tests/test_discussion.py
from channels.routing import URLRouter
from channels.testing import WebsocketCommunicator
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from catalog import routing
from catalog.models import Book, DiscussionMessage
from .base import LayerTestCase
User = get_user_model()
class DiscussionTests(LayerTestCase):
@classmethod
def setUpTestData(cls):
cls.book = Book.objects.create(title="Dune")
cls.alice = User.objects.create_user("alice")
cls.bob = User.objects.create_user("bob")
def connect_as(self, user, book_id=None):
"""Route like production, but put the user straight into the scope."""
book_id = book_id or self.book.pk
router = URLRouter(routing.websocket_urlpatterns)
communicator = WebsocketCommunicator(router, f"/ws/books/{book_id}/discussion/")
communicator.scope["user"] = user
return communicator
async def test_anonymous_is_refused(self):
connected, code = await self.connect_as(AnonymousUser()).connect()
self.assertFalse(connected)
self.assertEqual(code, 4401)
async def test_unknown_book_is_refused(self):
connected, code = await self.connect_as(self.alice, book_id=9999).connect()
self.assertEqual((connected, code), (False, 4404))
async def test_message_reaches_everyone_in_the_group(self):
alice, bob = self.connect_as(self.alice), self.connect_as(self.bob)
self.assertTrue((await alice.connect())[0])
self.assertTrue((await bob.connect())[0])
await alice.send_json_to({"text": "Spice must flow"})
for communicator in (alice, bob):
reply = await communicator.receive_json_from()
self.assertEqual(reply["author"], "alice")
self.assertEqual(reply["text"], "Spice must flow")
self.assertEqual(await DiscussionMessage.objects.acount(), 1)
await alice.disconnect()
await bob.disconnect()
async def test_invalid_payload_gets_an_error_not_a_broadcast(self):
alice, bob = self.connect_as(self.alice), self.connect_as(self.bob)
await alice.connect()
await bob.connect()
await alice.send_json_to({"text": " "})
self.assertEqual(
await alice.receive_json_from(), {"error": "text must be 1-500 characters"}
)
self.assertTrue(await bob.receive_nothing())
await alice.disconnect()
await bob.disconnect()
async def test_other_books_are_other_groups(self):
other = await Book.objects.acreate(title="Emma")
alice = self.connect_as(self.alice)
bob = self.connect_as(self.bob, book_id=other.pk)
await alice.connect()
await bob.connect()
await alice.send_json_to({"text": "hi"})
await alice.receive_json_from()
self.assertTrue(await bob.receive_nothing())
await alice.disconnect()
await bob.disconnect()
async def test_view_can_push_into_a_group(self):
await self.async_client.aforce_login(self.bob)
alice = self.connect_as(self.alice)
await alice.connect()
response = await self.async_client.post(
"/announce/", {"book_id": self.book.pk, "text": "Closing at six"}
)
self.assertEqual(response.status_code, 202)
self.assertEqual(
await alice.receive_json_from(),
{"id": 0, "author": "librarian", "text": "Closing at six"},
)
await alice.disconnect()
And the stack tests against the real config/asgi.py: session cookie, missing cookie, foreign and missing origins, an
ALLOWED_HOSTS override, and the ticket middleware:
# catalog/tests/test_stack.py
from unittest.mock import patch
from channels.routing import URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from channels.testing import WebsocketCommunicator
from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import override_settings
from catalog import routing
from catalog.middleware import TicketAuthMiddleware, issue_ticket
from catalog.models import Book
from config.asgi import application
from .base import LayerTestCase
User = get_user_model()
ORIGIN = (b"origin", b"http://localhost:8000")
class FullStackTests(LayerTestCase):
"""Drive the real asgi.py application: origin check, session auth, tickets."""
@classmethod
def setUpTestData(cls):
cls.book = Book.objects.create(title="Dune")
cls.alice = User.objects.create_user("alice")
def path(self):
return f"/ws/books/{self.book.pk}/discussion/"
async def test_session_cookie_authenticates_the_socket(self):
await self.async_client.aforce_login(self.alice)
session_id = self.async_client.cookies[settings.SESSION_COOKIE_NAME].value
cookie = f"{settings.SESSION_COOKIE_NAME}={session_id}".encode()
communicator = WebsocketCommunicator(
application, self.path(), headers=[ORIGIN, (b"cookie", cookie)]
)
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to({"text": "from a cookie"})
self.assertEqual((await communicator.receive_json_from())["author"], "alice")
await communicator.disconnect()
async def test_no_cookie_means_anonymous_and_is_refused(self):
communicator = WebsocketCommunicator(application, self.path(), headers=[ORIGIN])
self.assertEqual(await communicator.connect(), (False, 4401))
async def test_foreign_or_missing_origin_is_denied(self):
for headers in ([(b"origin", b"https://evil.example")], []):
communicator = WebsocketCommunicator(
application, self.path(), headers=headers
)
connected, _ = await communicator.connect()
self.assertFalse(connected, headers)
@override_settings(ALLOWED_HOSTS=["bookshelf.example"])
async def test_validator_reads_allowed_hosts_when_it_is_built(self):
# A stack built inside the test sees the overridden setting; the
# module-level `application` captured ALLOWED_HOSTS when it was imported.
stack = AllowedHostsOriginValidator(URLRouter(routing.websocket_urlpatterns))
ok = WebsocketCommunicator(
stack, "/ws/echo/", headers=[(b"origin", b"https://bookshelf.example")]
)
self.assertTrue((await ok.connect())[0])
await ok.disconnect()
bad = WebsocketCommunicator(
stack, "/ws/echo/", headers=[(b"origin", b"https://evil.example")]
)
self.assertFalse((await bad.connect())[0])
async def test_ticket_authenticates_clients_without_cookies(self):
ticket = issue_ticket(self.alice)
communicator = WebsocketCommunicator(
application, f"{self.path()}?ticket={ticket}", headers=[ORIGIN]
)
self.assertTrue((await communicator.connect())[0])
await communicator.disconnect()
async def test_forged_or_expired_ticket_is_anonymous(self):
stack = TicketAuthMiddleware(URLRouter(routing.websocket_urlpatterns))
forged = WebsocketCommunicator(stack, f"{self.path()}?ticket=forged")
self.assertEqual(await forged.connect(), (False, 4401))
with patch("catalog.middleware.TICKET_MAX_AGE", -1): # already too old
ticket = issue_ticket(self.alice)
expired = WebsocketCommunicator(stack, f"{self.path()}?ticket={ticket}")
self.assertEqual(await expired.connect(), (False, 4401))
$ python manage.py test catalog
Found 23 test(s).
System check identified no issues (0 silenced).
.......................
----------------------------------------------------------------------
Ran 23 tests in 0.445s
OK
Source: HttpCommunicator and ApplicationCommunicator
A few pitfalls the scratch tests ran into:
-
Disconnect what you connect. A communicator left open keeps its consumer task alive; the next test may receive its group messages. Every test above ends with
await communicator.disconnect()(or never got past a refusedconnect()). -
Module-level stacks capture settings.
AllowedHostsOriginValidatorreadsALLOWED_HOSTSwhen it is built, so@override_settings(ALLOWED_HOSTS=…)has no effect on the importedapplication; build the stack inside the test. -
Channels 4.3 changed
ApplicationCommunicator. When the application has already finished, sending input now returns its result instead of timing out later; tests that relied on a timeout must now assert theCancelledError. -
The database and the event loop. Data created in
setUpTestDatais visible to the consumer, which runs in the same test transaction; Django’sTestCasehandles that forasync deftests.
The Bookshelf discussion room, end to end
Put together, one message travels like this through the files above:
-
The book page loads
discussion.js, which opensws(s)://<host>/ws/books/<id>/discussion/; the browser sends the session cookie and itsOrigin. -
ProtocolTypeRouterseesscope["type"] == "websocket";AllowedHostsOriginValidatorchecks the origin againstALLOWED_HOSTS;AuthMiddlewareStackloads the session and the user;TicketAuthMiddlewareleaves them alone;URLRoutermatches the path and putsbook_idintoscope["url_route"]. -
DiscussionConsumer.as_asgi()builds a consumer for this connection.connect()refuses anonymous users (4401) and unknown books (4404), joins the groupbook-<id>and accepts. -
The user sends
{"text": "…"}.receive_json()validates it, stores it withacreate()and callsgroup_send("book-<id>", {"type": "discussion.message", …}). -
The channel layer (Redis in production) delivers the event to every channel in the group, on any server process. In each consumer
discussion_message()runs andsend_json()writes the frame to that browser, which appends it withtextContent. -
A closed tab triggers
disconnect(), which leaves the group.
The tests in Testing consumers cover each step; the Daphne run covers the network part.
Migrating from Channels 2 to Channels 4
Channels 3.0 (on PyPI 2020-10-30) moved to ASGI 3 to match Django 3.0’s own ASGI support; Channels 4.0 (2022-10-15)
removed what 3.0 had deprecated and unbundled Daphne; channels_redis 4.0 (2022-10-07) replaced aioredis with
redis-py.
Code written for Channels 2, including every Channels listing in the books, needs the changes below. Each row was
applied to the scratch project; the "Channels 2" column is from the 2.x documentation and the release notes.
| Area | Channels 2 | Channels 4 | Since |
|---|---|---|---|
Entry point |
|
|
3.0 |
HTTP handling |
Omitted |
Explicit |
deprecated 3.0, removed 4.0 |
Routing consumers |
The class itself: |
|
3.0 |
Application shape |
"Double callable": |
ASGI 3 single callable |
3.0, shim removed 4.0 |
Custom middleware |
Class instantiated per scope, returning an inner instance |
|
3.0 |
Server and |
Daphne installed with Channels; |
|
4.0 |
Other servers |
Daphne in practice |
Any ASGI server (Uvicorn, Hypercorn, Granian, …); |
4.0 |
Redis layer |
channels_redis 2.x on |
channels_redis 4.x on |
channels_redis 4.0 |
Database in async consumers |
|
Async ORM (Django 4.1+) or |
4.2 |
Signals |
|
Removed (unused since the 2.0 rewrite) |
4.0 |
Supported versions |
The Django 2.x era (the book uses Django 2.0/2.1 and Channels 2.1) |
Channels 4.3.x: Python 3.9+, |
4.1-4.3 |
A practical upgrade order: upgrade Django first (Channels 4.3 needs 4.2+), then pip install -U 'channels[daphne]'
channels-redis, move routing.py into asgi.py with the "http" key, add .as_asgi() to every route, rewrite custom
middleware on BaseMiddleware, put "daphne" at the top of INSTALLED_APPS, switch Redis hosts to URLs, and run
manage.py check (the daphne.E001 check and ImproperlyConfigured errors catch most of the rest). Finally, look for
synchronous ORM calls in async def handlers: they now raise SynchronousOnlyOperation instead of silently blocking.
Where the books are outdated
Marani’s Practical Django 2 and Channels 2 (Apress, 2019) is the only one of the section’s books that builds a Channels application, and its chapters 8-10 predate everything in the table above. Beyond the API changes, five patterns from it should not be copied:
-
Synchronous ORM calls inside async consumers. They block every connection on the process; current Django refuses them outright. Use the async ORM or
database_sync_to_async(Database access from consumers). -
The API token in the WebSocket URL. A long-lived credential in a query string ends up in logs and histories. Use the session cookie for browsers and a short-lived, single-purpose ticket for other clients (Clients that cannot send cookies).
-
Presence by polling Redis with
KEYS.KEYSscans the whole keyspace and blocks Redis while it runs, so polling it for "who is online" degrades every other client of that server. Track presence explicitly instead: add and remove members inconnect()/disconnect()in a structure you can read directly (a Redis set or a database table with a last-seen timestamp that heartbeats refresh), and broadcast changes through a group. -
Connections that are never closed. Long-lived consumers keep database connections across idle hours; current Channels closes them around client messages and provides
aclose_old_connections()for the rest (Database access from consumers). -
Treating
scope["query_string"]as text. It isbytes; decode it before parsing (Clients that cannot send cookies).
What still holds from the book is the architecture: consumers per connection, groups for fan-out, a Redis layer between processes, and testing with communicators.
What changed recently
-
Channels 4.3.2 (2025-11-20) confirmed Django 6.0 and Python 3.14 and added a
typesextra for thetypes-channelsstubs. It has no Django 6.1 classifier yet (checked 2026-10-03); it worked unchanged with Django 6.1.1 in the scratch project. -
Channels 4.3.0 (2025-07-28) raised the minimums to Python 3.9 and asgiref 3.9, fixed
ChannelsLiveServerTestCaseon Django 5.2 and changedApplicationCommunicatorto return the result of an application that has already finished. -
Channels 4.2.1 (2025-03-29) added official Django 5.2 LTS and Python 3.13 support, and warns about over-long group names.
-
Channels 4.2.0 (2024-11-15) brought the async-ORM-aware documentation,
aclose_old_connections()and automatic connection closing in async consumers, plus Django 5.1 support. -
Channels 4.1.0 (2024-04-03) made Django 4.2 the minimum and added the
headersargument ofaccept()andreasonon close. -
channels_redis 4.3.0 (2025-07-22) added Python 3.13, dropped 3.8 and requires Channels 4.2.2+.
-
Daphne 4.2.3 (2026-07-21) is the current server release.
-
Django has meanwhile grown its own async story (async views in 3.1, the async ORM in 4.1, async sessions and auth in 5.x, the Tasks framework in 6.0), so Channels is now needed only for WebSockets, channel layers and other non-HTTP protocols; a one-way live feed can also be an async streaming view (see Async Support).