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

pip install -U 'channels[daphne]' channels-redis; put "daphne" first in INSTALLED_APPS; set ASGI_APPLICATION and CHANNEL_LAYERS.

Installing and configuring Channels

Route connections

asgi.py: ProtocolTypeRouter keyed by "http"/"websocket", URLRouter for paths, Consumer.as_asgi() for each consumer.

asgi.py: ProtocolTypeRouter, URLRouter and as_asgi()

Handle a WebSocket

Subclass AsyncJsonWebsocketConsumer (or the sync/text/binary variants); implement connect, receive_json, disconnect.

Consumers

Keep an HTTP response open

AsyncHttpConsumer, overriding http_request for event-driven streams such as server-sent events.

HTTP consumers and server-sent events

Broadcast to many clients

group_add in connect, group_discard in disconnect, group_send with a "type" that names a handler method.

Channel layers and groups

Push from a view, signal or task

async_to_sync(get_channel_layer().group_send)(…​) in sync code, await it in async code.

Sending from views, signals and tasks

Know who is connected

AuthMiddlewareStack puts a lazy user in scope["user"]; reject in connect(); tickets for clients without cookies.

Authentication and sessions

Stop cross-site hijacking

Wrap the WebSocket router in AllowedHostsOriginValidator (outermost), and authorise every action.

Origin validation

Run it

daphne config.asgi:application (or manage.py runserver with Daphne installed); another ASGI server also works; proxy Upgrade.

Running it: Daphne in development and production

Test it

WebsocketCommunicator / HttpCommunicator / ApplicationCommunicator inside async def test_…​ methods, InMemoryChannelLayer.

Testing consumers

Upgrade an old project

Follow the checklist in Migrating from Channels 2 to Channels 4.

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.

sequenceDiagram autonumber participant B as Browser A participant S as Protocol server (Daphne) participant R as asgi.py routers and middleware participant A as Consumer A (one per connection) participant L as Channel layer (Redis in production) participant C as Consumer B (another connection, maybe another process) participant B2 as Browser B B->>S: GET /ws/books/1/discussion/ (Upgrade: websocket, Origin, Cookie) S->>R: application(scope, receive, send), scope.type = websocket R->>R: origin check, session and user into scope, URL route R->>A: new consumer for this scope A->>L: group_add("book-1", channel_name) A-->>B: websocket.accept B->>A: text frame {"text": "Spice must flow"} A->>A: validate, save to the database A->>L: group_send("book-1", type discussion.message) L-->>A: event for A's channel L-->>C: event for B's channel A-->>B: frame (handler discussion_message) C-->>B2: frame (handler discussion_message) B->>A: close A->>L: group_discard("book-1", channel_name)
Figure 1. Browser, consumer, channel layer and the other consumers

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.

flowchart TD srv["Protocol server: Daphne, Uvicorn, ..."] --> pt{"ProtocolTypeRouter
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"]
Figure 2. The default stack in asgi.py

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" in INSTALLED_APPS replaces runserver with an ASGI version (so manage.py runserver serves WebSockets in development). It must precede django.contrib.staticfiles; otherwise the check framework fails with daphne.E001: Daphne must be listed before django.contrib.staticfiles in INSTALLED_APPS (reproduced on 2026-10-03). Any other app that replaces runserver (the documentation names whitenoise.runserver_nostatic) conflicts for the same reason: keep daphne at the top.

  • "channels" in INSTALLED_APPS is optional in Channels 4: it only registers the runworker command (Background workers). Channels 2 required it for runserver; that role moved to Daphne.

  • ASGI_APPLICATION is the dotted path of the root application. The daphne command and other ASGI servers take the module path on their command line and ignore it, but Daphne’s runserver and runworker read it: without it runserver stops with CommandError: You have not set ASGI_APPLICATION, which is needed to run the server.

  • CHANNEL_LAYERS is optional. Leave it unset (or {}) if you only need independent WebSockets; consumers then have no channel_layer, and a groups attribute raises InvalidChannelLayerError on connect (Channel layers and groups).

  • ALLOWED_HOSTS is Django’s own setting; AllowedHostsOriginValidator reuses 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))
            )
        ),
    }
)

Four details in that file are not decoration.

  • get_asgi_application() runs first. It calls django.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 with noqa: E402. Importing them earlier fails with AppRegistryNotReady: Apps aren’t loaded yet. (or, when the settings module is not even set, the ImproperlyConfigured error 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 to get_asgi_application(). Without the key, HTTP requests are refused with ValueError: No application configured for scope type 'http'.

  • as_asgi() is the consumer’s as_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), passing initkwargs to the constructor, exactly like View.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 nested URLRouter instances. Nesting URLRouter`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.

  • URLRouter matches the path only, not the query string and not the method. The HTTP router in asgi.py lists the long-lived paths first and uses re_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 URLRouter raises ValueError: 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 outermost URLRouter strips 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.

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

self.scope

The connection’s scope dict: type, path, query_string, headers (raw byte pairs), subprotocols, client, plus whatever middleware and routers added: url_route, cookies, session, user.

self.channel_layer

The channel layer for channel_layer_alias (default "default"), or None when CHANNEL_LAYERS is empty.

self.channel_name

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()

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, use await and async-native libraries, the async ORM (aget(), acreate(), aexists(), async for) or database_sync_to_async (Database access from consumers).

  • SyncConsumer: Channels runs the whole handler in a thread-pool thread (using database_sync_to_async, so database connections are closed afterwards) and bridges self.send back 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

WebsocketConsumer

Synchronous. connect(), receive(text_data=None, bytes_data=None), disconnect(code); helpers accept(), send(), close().

AsyncWebsocketConsumer

The same methods, all async def (await self.accept(), await self.send(…​), await self.close()).

JsonWebsocketConsumer

Synchronous. receive_json(content, **kwargs) gets the decoded object; send_json(content, close=False) encodes it; override the encode_json / decode_json classmethods to change the format.

AsyncJsonWebsocketConsumer

Async variant; note that encode_json and decode_json are coroutine functions here.

The pieces you will use constantly:

  • connect() runs when the browser asks to open the socket. The default accepts every connection (so EchoConsumer.connect below is only there to be explicit). Call self.accept() to accept, self.accept("subprotocol") to choose one of the scope["subprotocols"] the client offered, self.accept(headers=[…​]) (Channels 4.1) to add response headers, or self.close() to refuse. You can raise channels.exceptions.AcceptConnection or DenyConnection from anywhere inside connect (reusable authentication or rate-limit helpers) instead of calling them.

  • receive() gets either text_data or bytes_data per frame, never both; the JSON consumers only accept text and raise ValueError on a binary frame (and on an empty text frame, because the check is if text_data:).

  • send(text_data=None, bytes_data=None, close=False) sends one frame; it raises ValueError unless 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’s group_expiry is 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

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:

  • connect reads the book id from scope["url_route"] and the user from scope["user"] (Authentication and sessions). Unauthenticated users and unknown books are refused before group_add, so nothing leaks to a stranger. disconnect still runs when connect refused, hence the hasattr guard.

  • The message "type": "discussion.message" is the event sent through the layer: Channels will call discussion_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_json validates 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).

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.

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_discard removes 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

channels.layers.InMemoryChannelLayer

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.

channels_redis.core.RedisChannelLayer

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.

channels_redis.pubsub.RedisPubSubChannelLayer

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 on_disconnect / on_reconnect events so a consumer can re-sync.

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

hosts

redis://localhost:6379

A list of Redis URLs (or dicts accepted by redis-py’s async client; Sentinel needs dicts with sentinels and master_name; a UNIX socket is unix:///path/to/redis.sock). Several hosts enable sharding; changing the list later loses some sharded data. The 4.3.0 source also still accepts (host, port) tuples, though the 4.0.0 changelog says tuples are no longer supported: prefer URLs.

prefix

asgi

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.

expiry

60

Seconds an undelivered message stays queued. Lower it to drop peaks, raise it to backlog them.

group_expiry

86400

Seconds a channel stays in a group after group_add. Must not be lower than the protocol server’s WebSocket timeout (Daphne’s --websocket_timeout, 86400 by default), or a still-connected socket silently drops out of its groups.

capacity

100

Messages a channel can hold before it refuses more.

channel_capacity

none

Per-channel capacities by name pattern (a dict of glob strings or compiled regexes).

symmetric_encryption_keys

none

A list of keys; the first encrypts, all decrypt, so keys can rotate without downtime. Needs the cryptography package (pip install channels-redis[cryptography]). Read them from the environment; keep them at least 32 bytes of entropy.

serializer_format

msgpack

msgpack or json; custom formats can be registered in channels_redis.serializers.registry.

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

get_channel_layer(alias="default")

The configured layer (from channels.layers), for use outside consumers; inside a consumer it is self.channel_layer.

await layer.send(channel, event)

Deliver event (a dict with a "type") to one channel. Raises channels.exceptions.ChannelFull when the channel is at capacity.

await layer.group_add(group, channel) / group_discard(group, channel)

Subscribe / unsubscribe a channel to a group.

await layer.group_send(group, event)

Deliver event to every channel in the group. Members whose channel is full are skipped without an error.

await layer.new_channel() / receive(channel) / flush()

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_required and @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_group in Testing consumers, and the SSE one is test_server_sent_events_stream_stays_open.

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 DiscussionConsumer saves the message and then broadcasts it).

  • Messages expire after expiry seconds (60 by default), and a channel holds at most capacity messages (100). A full channel makes send raise ChannelFull, and group_send skips 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 datetime raises TypeError (both checked with channels_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_expiry from the moment of group_add (see the options table), and disconnect() 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.

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

channels.sessions.CookieMiddleware

scope["cookies"], parsed from the cookie header.

channels.sessions.SessionMiddleware

scope["session"], a lazy session object built from SESSION_ENGINE and the cookie named by SESSION_COOKIE_NAME. SessionMiddlewareStack is the first two without authentication.

channels.auth.AuthMiddleware

scope["user"], the logged-in user or an AnonymousUser, looked up with the backend path stored in the session.

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() (or await scope["session"].asave() in async code) itself, or other connections and views never see the change.

  • channels.auth.login(scope, user) and logout(scope) exist (async functions; wrap them in async_to_sync from a sync consumer), and need the same explicit save(). 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()

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_AGE seconds 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 a database_sync_to_async function (Database access from consumers).

  • query_string is bytes. The ASGI scope carries the raw query string as bytes; it is decoded before parse_qs. Comparing it, or values sliced from it, with str never matches (the book’s bug), and the short illustrative middleware in the Channels documentation, which passes the whole raw query string to int(), only works when the query string is nothing but a number.

  • A fresh scope. scope = dict(scope) copies before writing, the same thing AuthMiddleware does, so the outer middleware’s dictionary is never mutated.

  • Placement. It runs inside AuthMiddlewareStack and only fills in a user when the cookie produced none. Forged and expired tickets give an AnonymousUser, and the consumer’s own check closes with 4401.

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

OriginValidator(app, allowed_origins)

Origins matching the list: a bare domain ("bookshelf.example"), a leading-dot pattern for subdomains (".bookshelf.example"), or a full scheme://host[:port] origin (port optional but recommended). "*" allows any origin, including none.

AllowedHostsOriginValidator(app)

OriginValidator built from Django’s ALLOWED_HOSTS, so the list lives in one place. With DEBUG = True and an empty ALLOWED_HOSTS it allows localhost, 127.0.0.1 and [::1], as Django does for the Host header.

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, as config/asgi.py does, so a hostile page is turned away before a session is even loaded.

  • A missing Origin is 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_HOSTS is read when the stack is built, i.e. when asgi.py is imported, not per connection. A test that overrides the setting must build its own stack (test_validator_reads_allowed_hosts_when_it_is_built in Testing consumers).

  • It is not authorisation. Origin checks stop other sites' pages; they do nothing against a malicious client that sets any Origin it likes. Authenticate in connect() 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 (WebsocketConsumer, JsonWebsocketConsumer, SyncConsumer)

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 (AsyncWebsocketConsumer, AsyncJsonWebsocketConsumer, AsyncConsumer, AsyncHttpConsumer)

Use the async ORM (aget(), acreate(), aexists(), acount(), async for) or wrap a sync function with channels.db.database_sync_to_async. A plain Book.objects.get() on the event loop raises SynchronousOnlyOperation — Django’s guard against blocking every other connection on the process.

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)

test_queries calls history_for() from an async def test and gets both rows back, oldest first. The points worth remembering:

  • database_sync_to_async is asgiref.sync.sync_to_async plus connection cleanup. Before and after the wrapped function it closes connections that are past CONN_MAX_AGE or broken, so a thread never reuses a dead connection. Plain sync_to_async does not; prefer the Channels version for ORM code inside consumers.

  • Evaluate inside the thread. A QuerySet is lazy: returning it unevaluated from the wrapped function moves the actual query back to the event loop, where it fails. list(…​), .first(), .count() or a values() 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 by group_send after an hour of silence may hold a connection the database server already dropped. Call await aclose_old_connections() before the first query in a while, as history_for() does.

  • Count the threads. Sync consumers and database_sync_to_async share one thread pool; each thread can hold a database connection. The pool size follows min(32, os.cpu_count() + 4) and can be capped with the ASGI_THREADS environment variable. Many open WebSockets on sync consumers can exhaust PostgreSQL’s max_connections; async consumers only borrow a thread for the query.

  • Transactions stay synchronous. transaction.atomic() cannot span await calls; put the whole transaction in one database_sync_to_async function (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.

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

-b/--bind, -p/--port

127.0.0.1, 8000

TCP address. -u/--unix-socket and --fd (a socket passed in by a supervisor) are alternatives; -e/--endpoint takes Twisted endpoint strings.

--proxy-headers

off

Use X-Forwarded-For/X-Forwarded-Port as the client address (scope["client"]). Enable it only behind a proxy you control; --proxy-headers-host and --proxy-headers-port choose other header names.

--root-path

empty

Mount the application under a URL prefix, as SCRIPT_NAME does for WSGI.

--ping-interval, --ping-timeout

20, 30 seconds

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.

--websocket_timeout

86400 seconds

Maximum lifetime of one WebSocket (-1 for unlimited). Clients must reconnect anyway; design for it.

--websocket-max-message-size, --websocket-max-frame-size

1 MiB each

Reject oversized messages before they reach your consumer.

--application-close-timeout

10 seconds

Grace period for a consumer to finish after the client disconnects before Daphne kills it.

-t/--http-timeout

none

Close HTTP requests (including long-lived HTTP consumers) that take longer than this.

--access-log, -v/--verbosity

stdout when verbosity >= 1, 1

Access log file (- for stdout); WSCONNECTING, WSCONNECT, WSREJECT and WSDISCONNECT lines show the WebSocket lifecycle.

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

WebsocketCommunicator(app, path, headers=None, subprotocols=None, spec_version=None)

WebSocket consumers and full stacks. connect() returns (True, subprotocol) on accept or (False, close_code) on refusal; then send_to()/send_json_to(), receive_from()/receive_json_from(), receive_nothing(), disconnect(). path may carry a query string; headers are (bytes, bytes) pairs.

HttpCommunicator(app, method, path, body=b"", headers=None)

One request/response round trip: await communicator.get_response() returns status, headers and body.

ApplicationCommunicator(app, scope)

Anything else: a hand-built scope, send_input(), receive_output(), receive_nothing(), wait(). The SSE stream and the ChannelNameRouter worker were tested with it.

ChannelsLiveServerTestCase

Browser tests (Selenium, Playwright) against a real Daphne in a subprocess; needs Daphne installed. serve_static serves static files like StaticLiveServerTestCase.

Source: Testing

Three habits keep the tests fast and isolated:

  • Use InMemoryChannelLayer in tests (the scratch settings already do; a production project overrides CHANNEL_LAYERS in 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.py to test the stack. A communicator’s scope is a plain dictionary; setting communicator.scope["user"] before connect() skips cookies and sessions, which keeps consumer tests about the consumer. Separate tests run the real application with 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, and receive_output() raises asyncio.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

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 refused connect()).

  • Module-level stacks capture settings. AllowedHostsOriginValidator reads ALLOWED_HOSTS when it is built, so @override_settings(ALLOWED_HOSTS=…​) has no effect on the imported application; 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 the CancelledError.

  • The database and the event loop. Data created in setUpTestData is visible to the consumer, which runs in the same test transaction; Django’s TestCase handles that for async def tests.

The Bookshelf discussion room, end to end

Put together, one message travels like this through the files above:

  1. The book page loads discussion.js, which opens ws(s)://<host>/ws/books/<id>/discussion/; the browser sends the session cookie and its Origin.

  2. ProtocolTypeRouter sees scope["type"] == "websocket"; AllowedHostsOriginValidator checks the origin against ALLOWED_HOSTS; AuthMiddlewareStack loads the session and the user; TicketAuthMiddleware leaves them alone; URLRouter matches the path and puts book_id into scope["url_route"].

  3. DiscussionConsumer.as_asgi() builds a consumer for this connection. connect() refuses anonymous users (4401) and unknown books (4404), joins the group book-<id> and accepts.

  4. The user sends {"text": "…​"}. receive_json() validates it, stores it with acreate() and calls group_send("book-<id>", {"type": "discussion.message", …​}).

  5. 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 and send_json() writes the frame to that browser, which appends it with textContent.

  6. 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

myproject/routing.py with application = ProtocolTypeRouter(…​); ASGI_APPLICATION = "myproject.routing.application"

config/asgi.py calling get_asgi_application() first; ASGI_APPLICATION = "config.asgi.application"

3.0

HTTP handling

Omitted "http" key routed to Channels' own AsgiHandler

Explicit "http": get_asgi_application() (or a catch-all path in an HTTP URLRouter); AsgiHandler, AsgiRequest and the static-files wrappers are removed

deprecated 3.0, removed 4.0

Routing consumers

The class itself: path("ws/chat/", ChatConsumer)

ChatConsumer.as_asgi() (optionally with init kwargs), one instance per connection

3.0

Application shape

"Double callable": init(self, scope), then call(self, receive, send)

ASGI 3 single callable call(self, scope, receive, send); the guarantee_single_callable() shim is removed

3.0, shim removed 4.0

Custom middleware

Class instantiated per scope, returning an inner instance

BaseMiddleware subclass with async def call(self, scope, receive, send), copying the scope before changing it

3.0

Server and runserver

Daphne installed with Channels; "channels" in INSTALLED_APPS provided the ASGI runserver

pip install 'channels[daphne]'; "daphne" in INSTALLED_APPS before django.contrib.staticfiles (daphne.E001); "channels" only for runworker

4.0

Other servers

Daphne in practice

Any ASGI server (Uvicorn, Hypercorn, Granian, …​); ChannelsLiveServerTestCase still needs Daphne

4.0

Redis layer

channels_redis 2.x on aioredis

channels_redis 4.x on redis-py (redis.asyncio); hosts as redis:// URLs (a password goes inside the URL); RedisPubSubChannelLayer as an alternative

channels_redis 4.0

Database in async consumers

database_sync_to_async only, connections cleaned up by hand

Async ORM (Django 4.1+) or database_sync_to_async; automatic close_old_connections around client messages and aclose_old_connections() for the rest

4.2

Signals

consumer_started / consumer_finished

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+, Django>=4.2; classifiers list 5.1, 5.2 and 6.0; runs on 6.1

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. KEYS scans 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 in connect()/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 is bytes; 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 types extra for the types-channels stubs. 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 ChannelsLiveServerTestCase on Django 5.2 and changed ApplicationCommunicator to 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 headers argument of accept() and reason on 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).