Real-Time Updates (Channels)
Most of a web app is request and response: the browser asks for a page, your code answers, and nothing else happens until the user clicks again. Channels are for the other case - when the server needs to speak first. A new chat message should appear for everyone in the room without anyone refreshing; a long import should push its progress; a notification should pop the moment it is created. That server-initiated traffic runs over a WebSocket, and Channels are how Proper organizes it.
If you have never used one: a WebSocket is a connection the browser opens once, with an ordinary HTTP request that asks to be upgraded, and then keeps open for as long as the page lives. After that, either side can send a message at any moment, without a request waiting for it. One connection per tab carries everything. With Channels you never touch the connection itself; you write Python classes that decide who may listen to what, and call broadcast() when something happens.
After reading this guide, you will know:
- The three pieces a real-time feature is built from, and how a message travels through them.
- How to define a channel, authenticate the connection, and authorize a subscription.
- How to broadcast - from inside a channel, and from a controller or background task.
- How to use the
cable.jsclient, or skip JavaScript entirely with Turbo Streams. - How the WebSockets are served, how to run them on several machines, and how to test channels without a server.
1. The shape of a channel
A real-time feature in Proper has three parts:
- The channel - a Python class, the WebSocket equivalent of a controller. It decides who may subscribe, handles messages from the client, and sends data back.
- The cable - the server that holds the WebSocket connections and delivers messages. Channels subscribe to named streams; when any code broadcasts to a stream, the cable sends the message to every subscriber.
cable.js- the browser client. It opens one WebSocket, manages your subscriptions, reconnects when the connection drops, and hands incoming data to your callbacks.
What connects them is the stream: a plain string like chat_42. A channel calls stream_from("chat_42") to start listening, and any code anywhere calls broadcast("chat_42", data) to deliver to every listener. The channel never addresses a specific browser - it addresses a stream, and the cable does the routing.
Here is the whole loop in miniature. A channel that joins a room's stream and re-broadcasts what it is told:
from ..router import router
from .app_channel import AppChannel
@router.channel()
class ChatChannel(AppChannel):
def subscribed(self):
self.stream_from(f"chat_{self.params['room']}")
def speak(self, data):
self.broadcast(
f"chat_{self.params['room']}",
{"message": data["message"]},
)
And the browser side - subscribe, render what arrives, send what the user types:
import { cable } from "cable"
cable.connect()
const chat = cable.subscribe(
"ChatChannel",
{ room: "general" },
{ received(data) { addMessageToDOM(data.message) } }
)
chat.perform("speak", { message: "hello" })
Follow one message through the pieces. Ana types "hello" in room general, where Bo also has the page open:
- Ana's page called
cable.subscribe("ChatChannel", {room: "general"}, ...)when it loaded. The cable created aChatChannelfor her, ran itssubscribed(), and the channel started listening to the streamchat_general. Bo's page did the same, so there are two subscriptions listening to that stream, on two connections. - Ana's page calls
chat.perform("speak", {message: "hello"}). The message travels over her WebSocket to the cable, which finds herChatChanneland calls itsspeak()method with{"message": "hello"}asdata. speak()callsself.broadcast("chat_general", {...}). The cable looks up who listens tochat_generaland writes the message to both connections, Ana's and Bo's.- In both browsers,
cable.jssees that the message belongs to theChatChannelsubscription and calls itsreceived(data)callback, which adds the line to the page.
Step 3 knows nothing about Ana or Bo. The channel addressed the stream, and the cable found who was listening. Every feature in this guide works this way, and the sections below take the pieces one at a time.
2. Installation
Channels is an addon. Install it with:
$ proper install channels
It creates:
config/channels.py-CABLE_PATH,CABLE_PORT, andCABLE, the cable backend. It is imported fromconfig/__init__.py.channels/app_channel.py- theAppChannelbase your own channels inherit from. It is to channels whatAppControlleris to controllers.channels/__init__.py- where each channel module is imported, so its@router.channel()decorator runs.assets/js/cable.js- the browser client. It is added to the import map as"cable", andassets/js/application.jsimports it, so every page can use it.tests/channels/- for your channel tests.
That is all the setup there is. proper run starts the cable in the web process, on CABLE_PORT - PORT + 1 by default, so 2301 in development. All the channels of a page share one WebSocket: a browser tab never opens more than one, however many channels it subscribes to.
3. Defining a channel
A channel is a subclass of AppChannel, registered with the router by the @router.channel() decorator. Use the generator to add one:
$ proper g channel Chat
It creates channels/chat_channel.py and imports it from channels/__init__.py. Fill it in:
from ..router import router
from .app_channel import AppChannel
@router.channel()
class ChatChannel(AppChannel):
def subscribed(self):
room = self.params["room"]
self.stream_from(f"chat_{room}")
def speak(self, data):
self.broadcast(f"chat_{self.params['room']}", {
"message": data["message"],
})
The channel is registered under its class name - "ChatChannel" - and that is the name clients subscribe with. To register it under another name, pass it to the decorator: @router.channel("chat").
The params are whatever the client passed when subscribing (here, {"room": "general"}), available as self.params for the life of the subscription. The channel name and the params together identify a subscription: a client can subscribe to ChatChannel twice with different rooms, and each one is a separate subscription, with its own channel instance.
Warning
Like controllers, the @router.channel() decorator only runs if the module is imported. The generator adds the import to channels/__init__.py; if you create a channel file by hand, add it there yourself.
Inside any channel method you have:
| Property | What it is |
|---|---|
.params |
The dict the client sent when subscribing |
.app |
The App - config, the cable, and the rest |
.channel_name |
The name it was registered under, e.g. "ChatChannel" |
.authenticated |
True when the connection has a logged-in user |
.user_id |
The id of that user, or None |
.request |
The WebSocket handshake, for reading headers and signed cookies |
4. The lifecycle: subscribed and unsubscribed
Two methods bracket a subscription. Override the ones you need:
@router.channel()
class ChatChannel(AppChannel):
def subscribed(self):
# Authorize, set up streams, send a welcome.
self.stream_from(f"chat_{self.params['room']}")
self.send({"status": "joined"})
def unsubscribed(self):
# Clean up anything subscribed() set up outside of streams.
pass
subscribed() runs once, when the client subscribes. It is where you decide whether the subscription is allowed (see Authorizing versus authenticating). Call stream_from() to start listening, and optionally send() a first message. What you send() here is held back and delivered just before the subscription is confirmed; if you reject(), it is discarded and never reaches the client.
unsubscribed() runs when the client unsubscribes, when the connection closes - the tab was closed, the network dropped, or the server closed it - and when the server stops. Proper removes the channel from all its streams before calling it, so you only need to undo work that lives elsewhere.
Warning
unsubscribed() does not run if the server process is killed or crashes, and for a connection that dies without closing (a laptop that went to sleep) it runs late: when the server notices the silence, about a minute later. Do not keep anything you cannot afford to lose solely in what unsubscribed() cleans up.
5. Authenticating a connection
A WebSocket handshake is an ordinary HTTP request, so it carries the same cookies your controllers see - including the signed session cookie of a logged-in user.
When the auth addon is installed, AppChannel uses that cookie to find the user, and exposes it as current.user, the same way a controller sees it:
from proper import current
from ..router import router
from .app_channel import AppChannel
@router.channel()
class InboxChannel(AppChannel):
def subscribed(self):
if not self.authenticated:
self.reject()
return
self.stream_from(f"inbox_{current.user.id}")
current.user is the server-verified user, and self.authenticated is True when there is one. current.user is available in every channel method: subscribed(), your actions, and unsubscribed().
This is the AppChannel the addon creates:
from proper.channels import Channel
from proper.models.base import ProperModel
try:
from ..models import Session, User
except ImportError:
# The auth addon is not installed, so channels stay anonymous.
Session = None
class AppChannel(Channel):
Session = Session
def find_user(self, user_id) -> "User | None":
return User.get_or_none(User.id == user_id)
Two things make it work:
Sessionis your app's session model. The first channel a connection subscribes to reads the session cookie, finds the session, and remembers the user's id for the whole connection. The other channels of that connection reuse it, so the cookie is read once per connection.current.auth_sessionis set only in thesubscribed()of that first channel.find_user()loads the user by that id. It runs before every other call to the channel - each action, thesubscribed()of the later channels, andunsubscribed()- socurrent.useris always fresh from the database. Change it to fit your app, for example to treat a deactivated user as no user.
Without the auth addon, Session is None, every connection is anonymous, current.user is None, and self.authenticated is False.
5.1 Clients without cookies
A mobile app or a script has no session cookie. The handshake is still an HTTP request, and the channel sees all of it in self.request: the path and query string (request.query), the Authorization header, and the client's address (request.remote_ip, which honors X-Forwarded-For from your proxy when it is one of the TRUSTED_PROXIES; see Deployment). A handshake without a peer address (only the in-memory test server's connect() without details sends none; the cable always does) gets no X-Forwarded-For, and its remote_ip is "". The session lookup is one method, find_session(), that reads the signed cookie by default. Override it in AppChannel to accept a token as well:
class AppChannel(Channel):
Session = Session
def find_session(self):
auth = self.request.headers.get("authorization", "")
if auth.startswith("Bearer "):
return Session.find_by_token(auth.removeprefix("Bearer "))
return super().find_session()
def find_user(self, user_id) -> "User | None":
return User.get_or_none(User.id == user_id)
It runs once per connection, when its first channel subscribes; return None to leave the connection anonymous. Prefer the header to a token in the query string, which ends up in proxy logs.
5.2 Authorizing versus authenticating
Authentication answers "who is connected?"; authorization answers "may they subscribe to this?". Do the second in subscribed(), using the first:
def subscribed(self):
room = Room.get_or_none(Room.id == self.params["room_id"])
if room is None or not room.has_member(current.user):
self.reject()
return
self.stream_from(f"room_{room.id}")
reject() denies the subscription: the client gets a reject_subscription message, the channel is discarded, and none of its actions can be called. The client never sends a stream name, only params; the channel checks them and picks the stream on the server. So a client cannot listen to a room it does not belong to.
6. Actions: messages from the client
Any public method of a channel can be called by the client as an action:
from proper import current
@router.channel()
class ChatChannel(AppChannel):
def subscribed(self):
self.stream_from(f"chat_{self.params['room']}")
def speak(self, data):
self.broadcast(
f"chat_{self.params['room']}",
{"message": data["message"], "sender": current.user.login},
)
def typing(self, data):
self.broadcast(
f"chat_{self.params['room']}",
{"typing": current.user.login},
)
The client calls chat.perform("speak", {message: "hello"}) and the speak method runs, with the payload as data. An action always receives data (an empty dict if the client sent nothing), so every action method takes that argument.
Actions are plain synchronous Python with a database connection ready, like a controller action. The calls of one connection - its subscriptions, actions and unsubscriptions - run one at a time, in the order they arrived, in a pool of worker threads (four by default, see workers in The cable server). A slow action keeps one of those threads busy, so move long work to a background task that broadcasts when it is done.
These names are refused as actions, with an error frame:
| Refused | Why |
|---|---|
Names starting with _ |
Private methods are not actions |
subscribed, unsubscribed |
Lifecycle hooks, not client-callable |
send, broadcast, reject |
Channel internals |
stream_from, stop_stream_from, stop_all_streams |
Stream control belongs to the server |
| Missing or non-callable names | There is nothing to run |
Any other public method is reachable from the client, so keep helpers private with a leading _.
There is one conventional action name: receive. The client's subscription.send(data) is shorthand for perform("receive", data), so if you define a receive(self, data) method it becomes the default handler for that subscription.
6.1 Replying to the caller
perform() returns a promise, and what the action returns is what it resolves with. To tell the caller that something was wrong, raise ActionError with a reason and anything else the page needs:
from proper.channels import ActionError
class ChatChannel(AppChannel):
def speak(self, data):
text = data.get("message", "").strip()
if not text:
raise ActionError("empty")
if len(text) > 500:
raise ActionError("too_long", max=500)
message = Message.create(room_id=self.params["room"], text=text)
self.broadcast(f"chat_{self.params['room']}", {"message": text})
return {"id": message.id}
try {
const { id } = await chat.perform("speak", { message })
input.value = ""
} catch (error) {
showError(error.reason === "too_long" ? `At most ${error.max} characters` : "Try again")
}
The promise rejects with the ActionError's data ({reason: "too_long", max: 500}), with {reason: "error"} when the action raised anything else (the error is logged, the client doesn't learn the details), with {reason: "not_subscribed"}, {reason: "invalid_action"} or {reason: "unknown_action"} when there was nothing to run, and with {reason: "timeout"} when no reply came in 10 seconds (perform(action, data, {timeout}) changes that). An action that returns nothing resolves with null. The connection and its other subscriptions keep working after any of these.
Two things to keep in mind. A timeout means the reply didn't arrive, not that the action didn't run: the server may have finished it, and a reply that comes later is dropped, so don't retry an action that creates or sends something on a timeout without the user's say-so, or have it take an id from the client so a repeat is ignored. And you don't have to wait for the reply: chat.perform("typing") on its own is fine, and a rejection nobody waits for is not reported anywhere.
7. Streams and broadcasting
Streams are the unit of delivery. A channel can stream from as many as it likes, and a broadcast to a stream reaches every channel - on every connection - that is listening to it.
| Method | What it does |
|---|---|
.stream_from(name) |
Start listening to a named stream |
.stop_stream_from(name) |
Stop listening to one stream |
.stop_all_streams() |
Stop listening to all of them |
.send(data) |
Send to this subscription only |
.broadcast(name, data) |
Send to every subscriber of a stream |
Use send() to answer the one client in front of you (a confirmation, a validation error); use broadcast() to tell everyone listening. The data can be anything Proper can encode as JSON, including a string of HTML.
A broadcast is encoded once and every subscriber receives the same frame; it does not go through send(), even one you override. To tell different users different things, give them different streams, such as f"notices_{user.id}".
7.1 Who should get it
The question "who should see this?" has one answer in Channels: whoever listens to the stream you broadcast to. So the shape of a feature is the shape of its stream names:
# Everyone: one stream, every channel listens to the same name
self.stream_from("announcements")
app.cable.broadcast("announcements", {"text": "Deploying in 5 minutes"})
# A group (a room, a team, a document): one stream per group
self.stream_from(f"chat_{room.id}")
app.cable.broadcast(f"chat_{room.id}", {...})
# One person: one stream per user, named after the verified user, never after params
self.stream_from(f"inbox_{current.user.id}")
app.cable.broadcast(f"inbox_{user.id}", {"unread": 3})
# Something that is happening: one stream per job, until it is done
self.stream_from(f"import_{self.params['job_id']}")
app.cable.broadcast(f"import_{job.id}", {"progress": 80})
A channel can listen to several at once - a NotificationsChannel might stream from announcements and inbox_{user.id} - and the page gets both through the same received() callback. Put a field in the data, such as {"kind": "unread", ...}, when the page needs to tell them apart.
7.2 Naming streams
Stream names are arbitrary strings. The convention is a descriptive prefix with a dynamic suffix:
self.stream_from(f"chat_{room_id}")
self.stream_from(f"inbox_{current.user.id}")
self.stream_from(f"document_{doc_id}_edits")
The name is the contract between the channel that listens and the code that broadcasts, and both sides have to spell it the same way. Nothing checks that they agree: a typo on one side delivers to no one, without an error. A small function that builds the name, used on both sides, avoids that:
def chat_stream(room_id):
return f"chat_{room_id}"
7.3 Broadcasting from a controller or a task
The most common broadcast does not come from a channel at all - it comes from an ordinary request, or a background task, that just changed something open pages should see. Any code with the app can broadcast through app.cable:
from ..models import Message
from ..router import router
from .app_controller import AppController
@router.resource("rooms/:room_id/messages")
class MessageController(AppController):
def create(self):
room_id = self.params["room_id"]
message = Message.create(room_id=room_id, text=self.params["text"])
# Push to everyone watching this room,
# then answer this request normally.
self.app.cable.broadcast(f"chat_{room_id}", {
"message": message.text,
"id": message.id,
})
self.response.redirect_to("Message.index", room_id=room_id)
A controller reaches the cable through self.app.cable; a background task imports the app:
from ..main import app
@app.queue.task()
def notify_user(user_id, payload):
app.cable.broadcast(f"inbox_{user_id}", payload)
The controller saves the message and redirects as usual, and the broadcast updates every open page. Background Tasks covers running the worker that the second example needs.
It doesn't matter which process the broadcast comes from. Only one process serves the WebSockets; a broadcast made in any other one - a task worker, a shell, the extra web processes of PROCESSES - is sent to it for you (see The cable server).
When one request makes several broadcasts, wrap them in app.cable.batch(). From a process that has to send them to the cable, they then travel together, in one request instead of one each:
with self.app.cable.batch():
self.app.cable.broadcast(f"room_{room.id}", html)
for user_id in member_ids:
self.app.cable.broadcast(f"unreads_{user_id}", {"room_id": room.id})
7.4 Closing a user's connections
A channel authorizes when it subscribes. If you take that access away later - remove someone from a room, ban them, sign them out - their open subscriptions keep receiving until they disconnect. app.cable.disconnect(user_id=...) closes every connection of that user, from any process. Their pages reconnect and subscribe again, and the channels that no longer authorize them reject the subscription:
membership.delete_instance()
self.app.cable.disconnect(user_id=membership.user_id)
8. Who is here: presence
A channel can list its connection among those present in a stream, with track(). The server keeps the list, tells the stream's subscribers who joins and leaves, and gives every new subscriber the list as it stands:
@router.channel()
class RoomChannel(AppChannel):
def subscribed(self):
if not self.authenticated:
self.reject()
return
stream = f"room_{self.params['room_id']}"
self.stream_from(stream)
self.track(stream, {"name": current.user.name, "avatar": current.user.avatar_url})
def away(self, data):
self.update_presence({"name": current.user.name, "status": "away"})
cable.subscribe("RoomChannel", { room_id: 42 }, {
presence(users, change) {
renderWhoIsHere(Object.values(users)) // {key: data}, as of now
if (change?.event === "join") toast(`${change.data.name} is here`)
},
})
The presence callback runs with the whole list when the subscription is confirmed (change is null) and each time it changes; change is {event, userId, data}, with event one of join, leave or update. On the server, app.cable.presence(stream) returns {key: {"data": ..., "connections": n}} and app.cable.presence_stats(stream) the counts, from any process that serves the WebSockets.
What the list keys on:
- A logged-in user is listed by their id. Three tabs are one entry:
joingoes out with the first,leavewith the last. That is also whatunsubscribed()can't give you, because it runs once per tab. - An anonymous connection gets a random key, so a visitor with three tabs counts three times.
track(stream, data, key=...)lists the connection under a key of your choosing, for example a bot's name. The key is decided insubscribed(), on the server. Never take it fromparams: a client could pose as anyone. One key per connection; a secondtrack()with another is an error.
A connection leaves a stream's list with untrack(stream), with stop_stream_from(), when the client unsubscribes, and when the connection closes, including when the browser is killed: the server notices the connection is gone and removes the entry. update_presence(data) changes the data in every stream the connection is present in. track() needs stream_from() first.
presence=False in CABLE turns it off (track() then fails). The data of a user may be 4 KB at most as JSON (presence_max_data_size), and presence_max_members caps the users of a stream (0, the default, is no limit); past either, track() and update_presence() raise ValueError. With RedisCable the list is one for all the machines (see Several machines).
9. The client: cable.js
cable.js is an ES module that owns the connection, your subscriptions, and reconnection. It is in the import map as "cable":
import { cable } from "cable"
cable.connect()
const chat = cable.subscribe("ChatChannel", { room: "general" }, {
connected() { console.log("subscribed") },
disconnected() { console.log("connection lost") },
rejected() { console.log("subscription denied") },
received(data) { addMessageToDOM(data) },
})
chat.perform("speak", { message: "hello" }) // call an action; a promise with its reply
chat.send({ message: "hello" }) // shorthand for perform("receive", data)
chat.unsubscribe() // leave this channel
cable.disconnect() // close the socket, stop reconnecting
cable.connect() opens the WebSocket. Calling it again while it is open, or opening, does nothing, so every script of the page can call it. With no argument, it connects to /cable on the current host (ws:// on http, wss:// on https); in development, to the port the page announces (see The cable server). If you change CABLE_PATH, pass the full URL to connect().
cable.subscribe(channel, params, callbacks) returns a subscription. You can subscribe before the connection is open: the subscription is sent as soon as it is. If you pass only two arguments and the second has callbacks in it, it is taken as the callbacks, with empty params.
The four callbacks are optional:
| Callback | Called when |
|---|---|
connected(info) |
The server confirmed the subscription (again after each reconnection). info is {reconnected, recovered}, see Missed broadcasts |
received(data) |
A send() or a broadcast arrived for this subscription |
rejected() |
The channel called reject(), or no channel has that name |
disconnected() |
The connection closed, or you called unsubscribe() |
presence(users, change) |
The channel tracks who is in a stream (see Who is here): the list on confirmation, and each change |
When the connection drops, cable.js reconnects on its own, subscribes everything again, and gets the broadcasts it missed (see Missed broadcasts), so a short network problem is invisible to your code. A perform() made meanwhile waits, and is sent once the connection and its subscriptions are back; its promise still rejects with {reason: "timeout"} if that takes longer than its timeout, and with {reason: "offline"} if more than 100 calls pile up. It waits 1 second before the first attempt and twice as long before each next one, up to 30 seconds, and it never gives up. The server pings every connection every CABLE_PING_INTERVAL seconds; a connection that goes silent for three intervals (and at least 10 seconds) is treated as dead, closed, and opened again. This catches a laptop that went to sleep or a proxy that dropped the connection without telling anyone. cable.disconnect() is the only thing that stops the reconnecting.
All subscriptions share the one connection, so holding several is normal and cheap:
const general = cable.subscribe(
"ChatChannel", { room: "general" }, { received: render })
const random = cable.subscribe(
"ChatChannel", { room: "random" }, { received: render })
const inbox = cable.subscribe(
"InboxChannel", { received: showToast })
9.1 Where the JavaScript goes
New apps load assets/js/application.js as a module from the layout and navigate with Turbo Drive, which swaps the <body> on each click instead of reloading. Two consequences for subscriptions:
cable.connect()and anything every page needs belong inapplication.js. The module runs once per real page load, and the connection survives Turbo navigations, so a tab opens one WebSocket and keeps it.- A subscription for one page must end when that page goes away. A
cable.subscribe()at the top ofapplication.jswould subscribe once and never let go; one inside aturbo:loadhandler would subscribe again on every visit, piling up. Tie it to an element instead, so it unsubscribes when the element leaves the DOM. For HTML broadcasts,<turbo-stream-channel>does exactly that (see Broadcasting HTML). For JSON, a Stimulus controller, which new apps also include, has the same two moments:
import { Controller } from "@hotwired/stimulus"
import { cable } from "cable"
class RoomController extends Controller {
static values = { id: Number }
connect() {
this.subscription = cable.subscribe("RoomChannel", { room_id: this.idValue }, {
received: (data) => this.render(data),
})
}
disconnect() {
this.subscription.unsubscribe()
}
render(data) { ... }
}
window.Stimulus.register("room", RoomController)
{#js "js/room-controller.js" #}
<div data-controller="room" data-room-id-value="{{ room.id }}">...</div>
The view loads the controller with the {#js #} tag, as the auth addon's password.jx does, and the element's data-controller attribute starts it. When Turbo replaces the body, disconnect() runs and the subscription ends with the page.
9.2 Missed broadcasts (recovery)
The server keeps the last broadcasts of each stream: 128 by default, for 5 minutes after the last one. When a connection comes back, cable.js tells the server where it was in each stream, and the server sends what was broadcast since. They arrive through received() like any other, in order, and nothing arrives twice. A connection that falls too far behind is closed (see Slow clients), and gets what it missed when it reconnects, the same way.
connected() is where the page learns whether it is up to date. It gets an object:
cable.subscribe("ChatChannel", { room: "general" }, {
connected({ reconnected, recovered }) {
if (reconnected && recovered === false) loadMessages() // too much was missed
},
received: render,
})
| Field | Value |
|---|---|
reconnected |
false the first time, true after each reconnection |
recovered |
true when every missed broadcast was sent; false when some couldn't be: the server restarted, more than the buffer holds were missed, or the stream had no history; null when there was nothing to ask for (the first subscription, or a stream without broadcasts before the connection dropped) |
With recovered === false, load the state again from the server, as the page did when it first rendered. The <turbo-stream-channel> element does this by dispatching an event (see Broadcasting HTML).
What recovery does not cover: a stream that had no broadcasts before the connection dropped has no position to recover from, so what it broadcast meanwhile is lost (recovered is null for it); and send() messages to one subscription are not kept, only broadcasts. On the server, recovery=False in CABLE turns it off, and recovery_buffer_size, recovery_ttl and recovery_memory_budget size the buffers (see The cable server).
10. Broadcasting HTML (Turbo Streams)
The broadcasts so far send JSON, and the received() callback rebuilds the DOM by hand - writing again, in JavaScript, markup you already have as a Jx component.
Proper includes Turbo, so instead you can broadcast the rendered component wrapped in a <turbo-stream> and let Turbo apply it. A live list then needs no JavaScript of your own.
A <turbo-stream> is HTML that names a DOM operation and a target:
<turbo-stream action="append" target="messages">
<template><li>Ana: hello</li></template>
</turbo-stream>
The turbo_stream builder has a method for each operation. Each one renders a Jx component, or takes ready-made HTML:
from proper import turbo_stream
turbo_stream.append(
"messages", # the id of the element to update
"message.jx", # the component to render
message=message, # arguments for the component
)
# -> <turbo-stream action="append" target="messages">
# <template>...</template>
# </turbo-stream>
turbo_stream.update("unread_count", html="3")
turbo_stream.remove(message) # a model: its dom_id is the target
turbo_stream.remove(dom_id(message, "list"))
turbo_stream.replace(targets=".draft", html="") # every element matching a CSS selector
The first argument is the id of the element to act on, or a model instance, whose dom_id is used. targets= takes a CSS selector instead, to act on every matching element. The method name is the operation:
| Action | Effect |
|---|---|
append / prepend |
Add the fragment as the last / first child of the target |
before / after |
Insert the fragment as a sibling before / after the target |
replace |
Swap the target element itself |
update |
Replace the target's contents, keep the element |
remove |
Remove the target (no fragment) |
morph |
Update the target by morphing, keeping unchanged nodes |
refresh |
Reload the page (takes no target) |
Broadcast it like any other data:
from proper import current, turbo_stream
...
def create(self):
room_id = self.params["room_id"]
message = Message.create(
room_id=room_id,
text=self.params["text"],
author=current.user,
)
self.app.cable.broadcast(
f"chat_{room_id}",
turbo_stream.append("messages", "message.jx", message=message),
)
self.response.redirect_to("Message.index", room_id=room_id)
To subscribe a page, you don't need to write JavaScript either. Use the <turbo-stream-channel> element, which cable.js defines:
{#import "message.jx" as Message #}
<turbo-stream-channel channel="ChatChannel" params='{"room_id": 42}'>
</turbo-stream-channel>
...
<ul id="messages">
{% for message in messages %}
<Message message={{ message }} />
{% endfor %}
</ul>
The element connects, subscribes to the channel with those params, and hands every message to Turbo, which appends the new <li>. When the element leaves the page, it unsubscribes. The same Message component renders the initial list and every live update, so the markup is defined in one place.
To subscribe from JavaScript instead, use streamFrom:
import { streamFrom } from "cable"
const subscription = streamFrom("ChatChannel", { room_id: 42 })
Authorization doesn't change: the element sends params, never a stream name, and the channel's subscribed() still decides with reject() and picks the stream on the server (see Authorizing versus authenticating).
After a reconnection, the <turbo-stream> fragments the page missed are applied as they arrive. When they can't be recovered (see Missed broadcasts), the element dispatches a turbo-stream-channel:gap event, which bubbles, so the page can load the content again:
document.addEventListener("turbo-stream-channel:gap", (event) => {
Turbo.visit(location.href, { action: "replace" }) // or reload a frame
})
streamFrom(channel, params, { onGap }) takes the same as a function.
Note
The same fragment can also be the response of a controller, to a form submitted with Turbo. See Turbo for *.turbo_stream.jx views and the stream tag.
11. The cable server: Cable
The channels addon configures this cable:
CABLE_PATH = "/cable"
CABLE_PORT = int(os.getenv("CABLE_PORT", int(os.getenv("PORT", 2300)) + 1))
CABLE: dict = {"type": "proper.channels.Cable"}
Cable serves the WebSockets with Bearings, the same server that runs the app, inside the web process. Your channels run in Python, in its worker threads. Underneath, a stream is a Bearings topic, and a broadcast() gives Bearings one encoded frame, which it writes to every subscriber without going back to Python. With thousands of clients on one stream, this is what keeps up.
How it runs:
proper runstarts the cable in its web process, before the first request, and stops it with the server. The cable listens on its own port,CABLE_PORT. Under another server, callapp.cable.start_server()andapp.cable.stop_server()yourself.start_server()returns once the cable listens, and raisesOSError, with the reason, when the port can't be listened on (it is taken, or the cable'shostis not an address of this machine).- Only that process serves the WebSockets. Every other process that loads the app - the extra copies of
PROCESSES, a task worker, a shell - sends its broadcasts anddisconnect()s to it, signed with the app's secret keys, as aPOSTtoCABLE_PATHon127.0.0.1, portCABLE_PORT + 1. If the cable is down, the broadcast is lost and a warning is logged; the request that made it still finishes normally. One whose answer takes more than 2 seconds (the cable answers once it has delivered it) is sent again, and the cable ignores the copy. - In production, your reverse proxy routes
CABLE_PATHtoCABLE_PORT. Deployment shows the nginx block. - In development there is no proxy. When
DEBUGis on,render_importmap()adds a<meta name="cable-port">tag to the page, andcable.jsconnects to that port on the same host name.
The settings, in config/:
| Setting | Default | What it is |
|---|---|---|
CABLE |
{} |
The cable backend: {"type": ...} and its options |
CABLE_PATH |
"/cable" |
The URL path of the WebSockets, behind the proxy |
CABLE_PORT |
0 |
The port of the WebSockets (the addon sets PORT + 1) |
CABLE_ALLOWED_ORIGINS |
[] |
Other sites allowed to open a WebSocket (see below) |
CABLE_PING_INTERVAL |
3 |
Seconds between the cable's pings to every connection: a whole number, at least 1, and less than the cable's idle_timeout (60), which closes a connection that answers no ping for that long |
CABLE_MAX_PENDING_BYTES |
4 MB | How far behind a client may fall; see "Slow clients" below |
And the options of Cable, in CABLE:
| Option | Default | What it is |
|---|---|---|
port |
CABLE_PORT |
Port of the WebSockets |
host |
"0.0.0.0" |
Address to listen on; a host name is looked up |
forward_port |
port + 1 |
Port, on 127.0.0.1, where the other processes send their broadcasts |
loops |
4 |
Threads that read and write the WebSockets; 0 is one per CPU |
workers |
4 |
Threads that run the channels' code |
max_connections |
100000 |
Connections open at once; more wait to be accepted |
max_message |
1 MB | Bytes of a message from a client; a larger one closes its connection |
max_subscriptions |
0 |
Channels a connection may subscribe to; past it, a subscription is rejected with too_many_subscriptions. 0 is no limit |
idle_timeout |
60 |
Seconds; a connection that answers no ping for that long is closed |
recovery |
True |
Keep the last broadcasts of each stream, for clients that reconnect (see Missed broadcasts) |
recovery_buffer_size |
128 |
How many broadcasts of each stream it keeps |
recovery_ttl |
300 |
Seconds without broadcasts before a stream's are dropped |
recovery_memory_budget |
256 MB | Bytes for the broadcasts of all the streams; past it, those of the streams with the oldest last broadcast are dropped |
presence |
True |
Keep who is in each stream, for track() (see Who is here) |
presence_max_data_size |
4096 |
Bytes of a user's presence data, as JSON |
presence_max_members |
0 |
Users present in a stream; 0 is no limit |
shutdown_grace |
1 |
Seconds the WebSockets have to close when the server stops |
CABLE = {
"type": "proper.channels.Cable",
"loops": 8,
"workers": 8,
}
loops decides how many broadcasts per second the cable delivers. On a 16-core machine, one stream with 1000 clients got about 5 million messages a second with one loop, 14 million with the default 4, and 25 million with 16; each costs about the same CPU. The cable shares the machine with the threads that run your app, so raise it when the WebSockets are the busy part, and lower it on a small server.
11.1 How the cable is doing
app.cable.health() returns a dict for a health endpoint or a log line: serving (whether this process serves the WebSockets), connections, users (with a logged-in user), streams (subscribed here), and under server the server's counters: connections, and how many streams have a recovery buffer and a presence list. In a process that serves none, {"serving": False, "connections": 0, "streams": 0}.
@router.get("/up")
def up(self):
return self.render_json(self.app.cable.health())
For graphs and alerts over time, the cable also exports Prometheus metrics. The process that serves the WebSockets answers GET /metrics on the loopback port the other processes forward to, 127.0.0.1:CABLE_PORT + 1: proper_cable_connections, proper_cable_streams and proper_cable_users. Point Prometheus at that port on each machine. It is a loopback port, so the metrics are never exposed to the outside. app.cable.metrics() returns the same text, empty where nothing is served.
scrape_configs:
- job_name: proper-cable
static_configs:
- targets: ["10.0.0.1:2302", "10.0.0.2:2302"]
11.2 Slow clients
A client that stops reading - a frozen tab, a very bad network - would make messages pile up in memory. So each connection has a limit, CABLE_MAX_PENDING_BYTES (4 MB): a client that falls that far behind is closed. cable.js reconnects and subscribes again, and gets what it missed. A client that reads slowly but keeps up stays.
12. Several machines: RedisCable
Cable serves the WebSockets of one machine. To run the app on several, behind a load balancer, use RedisCable: the same cable, with Redis carrying the broadcasts between machines. Your channels and your code don't change, only the config:
CABLE = {
"type": "proper.channels.RedisCable",
"url": os.getenv("REDIS_URL", "redis://localhost:6379/0"),
"prefix": "myapp:cable:",
}
It needs the redis package (uv add redis).
Each machine's web process serves its own WebSockets, as with Cable. A broadcast() goes to the subscribers of the process that makes it, if it serves any, and to Redis, from where the web processes of the other machines deliver it to theirs. A process without WebSockets - a task worker, a shell - only publishes to Redis, so nothing is sent over CABLE_PORT + 1. disconnect() reaches every machine the same way.
| Option | Default | What it is |
|---|---|---|
url |
"redis://localhost:6379/0" |
Redis connection URL |
prefix |
"proper:cable:" |
Prefix of its Redis channel; give each app its own if several share one Redis |
It takes every option of Cable as well.
If the connection to Redis drops, the cable reconnects on its own, waiting longer each time, up to 30 seconds. Meanwhile, each machine's clients still get the broadcasts made on that machine; what other machines publish in that time is lost, and a broadcast that can't reach Redis is lost with a warning.
Each machine keeps its own recovery buffers, with their own positions. A client that reconnects to the same machine gets what it missed; one the load balancer sends to another machine gets recovered: false, and should load the state again. Pin a client to a machine (sticky sessions on CABLE_PATH) if that matters.
Presence is one list for all the machines, kept in Redis: presence() shows who is in a stream anywhere, and a user joins with their first connection on any machine and leaves with their last. A machine that dies is noticed within 15 seconds, and its users leave. Without Redis, each machine lists only its own.
Keep in mind that RedisCable shares broadcasts between machines, not state. Redis pub/sub carries messages; it doesn't store them. A list of who is online across machines, or the last value for a client that subscribes late, is something you keep yourself.
13. Checking where a WebSocket comes from
Browsers send a site's cookies with any WebSocket to it, even one opened by a page of another site. Without a check, a malicious page could open a WebSocket to your app with your user's session. So the cable checks the Origin header of the handshake, and accepts:
- A handshake with no
Origin(it doesn't come from a browser). - One from the same host the WebSocket was opened to, or from the app's
HOST. - In
DEBUG, one fromlocalhostor127.0.0.1on the app'sPORT. - One listed in
CABLE_ALLOWED_ORIGINS.
Any other is refused with a 403, and so is an Origin that is not a valid URL (http://[abc]). To allow another site, list it:
CABLE_ALLOWED_ORIGINS = ["https://admin.example.com"]
14. Without channels
An app without the channels addon has CABLE = {}: a cable that serves no WebSockets, so broadcast() reaches no one (it is logged at the debug level) and doesn't fail. Setting CABLE_PORT with an empty CABLE is a ConfigError when the app starts.
15. When nothing arrives
A broadcast to a stream nobody listens to is not an error: it reaches no one, and nothing is logged. So when a page doesn't update, there is no message to read, and it helps to check in this order:
- Is the page connected? In the browser's developer tools, the Network panel has a WS filter. The
cableconnection should be there with status 101, and its Messages tab shows every frame in both directions: yoursubscribecommand, the server'sconfirm_subscription(orreject_subscription), the pings, and each broadcast. In development, the connection goes toCABLE_PORT(2301), announced by a<meta name="cable-port">tag in the page; in production, toCABLE_PATHthrough your proxy (see Deployment). - Was the subscription confirmed? A
reject_subscriptionmeanssubscribed()calledreject(), raised (the server log has the traceback), or no channel has that name: the channel module isn't imported inchannels/__init__.py, or the name differs from the class name (@router.channel("chat")registerschat, notChatChannel). The confirmation lists the subscription'sstreams: if the one you broadcast to isn't there,subscribed()didn'tstream_from()it. - Do the names match? The stream in
stream_from()and the one inbroadcast()must be the same string.f"chat_{room.id}"andf"chat_{room_id}"look alike and differ when one is an int and the other a string from the URL. In a shell,app.cable.streamsshows the streams with subscribers right now, and how many. - Is the broadcast reaching the cable? From a task worker or a shell, the broadcast is forwarded to the web process; a warning in the log says when it can't be reached.
app.cable.health()says whether a process serves the WebSockets and how many connections it has. - Is it the data?
received()gets exactly what you broadcast, after a round trip through JSON: adatebecomes a string, a model has to be turned into a dict first. The Messages tab shows the frame as it arrived.
16. Testing channels
You don't need a running server to test a channel. The test client opens a WebSocket session served from memory: it subscribes, calls actions, and disconnects, and you check the frames that come back.
These tests are async, so they need pytest-asyncio:
$ uv add --dev pytest-asyncio
import pytest
@pytest.mark.asyncio
async def test_speak_broadcasts_to_the_room(client):
ws = client.websocket()
task = await ws.connect()
confirm = await ws.subscribe("ChatChannel", room="general")
assert confirm["type"] == "confirm_subscription"
await ws.send_action(
"ChatChannel",
"speak",
{"message": "hi"},
room="general",
)
msg = await ws.receive()
assert msg["type"] == "broadcast"
assert msg["data"] == {"message": "hi"}
await ws.close()
await task
The session's methods:
| Method | What it does |
|---|---|
await ws.connect() |
Opens the connection. Returns a task that ends when the connection closes; await it after close(). The handshake carries the client's default headers (the cookie from sign_in(), an authorization header you set) and the path given to client.websocket(path), CABLE_PATH by default, which may have a query string |
await ws.subscribe(channel, positions=None, **params) |
Subscribes, and returns the first frame the app sends back. positions, {stream: [{"e": ..., "o": ...}]} from the stamps of received broadcasts, asks for the ones broadcast since |
await ws.send_action(channel, action, data, **params) |
Calls an action, without asking for a reply |
await ws.perform(channel, action, data, **params) |
Calls an action and returns its reply: {"type": "reply", "status": "ok", "data": <what it returned>}, or "status": "error" with {"reason": ...} in data. Skips what the action sent before replying |
await ws.unsubscribe(channel, **params) |
Unsubscribes |
await ws.receive(timeout=1.0) |
The next frame, parsed from JSON. Raises TimeoutError if nothing arrives |
await ws.close() |
Disconnects, which runs unsubscribed() |
ws.client_send(data), ws.client_send_text(text) |
Sends a raw frame, to test what the app does with bad input |
subscribe() returns the first frame the app sends. If subscribed() calls send(), that message comes first, and the confirmation is the next receive().
Several sessions in one test share the same cable, so you can open two and check that a broadcast reaches both. A broadcast from your test code, client.app.cable.broadcast(...), reaches them too.
To test an authenticated channel, sign a user in first: the handshake carries the same session cookie an HTTP request would.
@pytest.mark.asyncio
async def test_anonymous_users_are_rejected(client):
ws = client.websocket()
await ws.connect()
reply = await ws.subscribe("InboxChannel")
assert reply["type"] == "reject_subscription"
await ws.close()
@pytest.mark.asyncio
async def test_users_get_their_inbox(client, session):
client.sign_in(session)
ws = client.websocket()
task = await ws.connect()
reply = await ws.subscribe("InboxChannel")
assert reply["type"] == "confirm_subscription"
assert reply["streams"] == [f"inbox_{session.user_id}"]
await ws.close()
await task
The session runs the app's cable from memory (app.cable.serve_in_memory()): no port, no threads, and each message is handled right away. With an app whose cable serves no WebSockets (CABLE = {}), connect() raises a RuntimeError. The Testing guide covers the TestClient and sign_in().
Note
As you can see, testing channels is one of the few places where the well-hidden async nature of Proper leaks into your code. Sorry about that.
17. The wire protocol
cable.js speaks this so you don't have to, but the frames are useful to know for debugging, or for writing a client in another language. Every message is a JSON object.
The client sends three commands - subscribe, message (call an action), and unsubscribe:
{ "command": "subscribe", "channel": "ChatChannel",
"params": {"room": "general"},
"positions": {"chat_general": [{"e": "0000abcd", "o": 41}]} }
{ "command": "message", "channel": "ChatChannel",
"params": {"room": "general"}, "action": "speak",
"data": {"message": "hi"}, "id": 7 }
{ "command": "unsubscribe", "channel": "ChatChannel",
"params": {"room": "general"} }
The id of a message is optional: with one, the server answers with a reply.
The server sends back confirm_subscription, reject_subscription, message (from send()), broadcast (from broadcast()), reply and error, plus the cable's ping:
{ "type": "confirm_subscription", "channel": "ChatChannel",
"params": {"room": "general"}, "streams": ["chat_general"],
"positions": {"chat_general": {"e": "0000abcd", "o": 42}},
"recovered": true, "presence": {"chat_general": {"7": {"name": "Ana"}}} }
{ "type": "reject_subscription", "channel": "ChatChannel",
"params": {"room": "general"} }
{ "type": "message", "channel": "ChatChannel",
"params": {"room": "general"}, "data": {"message": "hi"} }
{ "tp": "chat_general", "e": "0000abcd", "o": 42,
"c": "P", "type": "broadcast", "stream": "chat_general",
"data": {"message": "hi"} }
{ "type": "reply", "id": 7, "channel": "ChatChannel",
"params": {"room": "general"}, "status": "ok", "data": {"id": 42} }
{ "type": "reply", "id": 7, "channel": "ChatChannel",
"params": {"room": "general"}, "status": "error",
"data": {"reason": "too_long", "max": 500} }
{ "type": "error", "reason": "not_subscribed" }
{ "c": "WSE", "t": "ping", "p": {} }
{ "c": "WSE", "t": "presence_join", "p": {"topic": "chat_general",
"user_id": "7", "data": {"name": "Ana"}} }
A broadcast is one frame, the same for every subscriber, so it can't carry each subscription's channel and params. It names the stream instead, and confirm_subscription lists the streams of the subscription, so the client delivers a broadcast to every subscription that streams from it. Ignore the c field.
The tp, e and o fields are the recovery stamp: the stream, the epoch of its buffer (eight hex digits; it changes when the server restarts) and the offset of the broadcast in it. A subscribe may carry positions, the last o the client saw per stream and epoch (a list per stream, because on several machines each stamps its broadcasts with its own epoch; one {"e", "o"} is accepted too); the server sends again the broadcasts after them, for the streams the channel streams from, and they may arrive before the confirmation. The confirmation has the current positions of the streams (null for one without broadcasts yet) and recovered: true when everything asked for was sent again, false when some couldn't be, null when nothing was asked. Sending subscribe again for an existing subscription, with positions, also recovers. Without recovery (recovery=False), broadcasts carry no stamp and recovered is always null.
Subscribing again to a subscription that already exists only sends its confirmation again. A reject_subscription has "reason": "unknown_channel" when no channel has that name, and "reason": "error" when subscribed() raised an exception; a subscription your own reject() turned away has no reason.
A reply answers the message with the same id: status: "ok" with what the action returned as data (null if nothing), or status: "error" with a reason in data: the one of an ActionError, along with its other data; error when the action raised anything else; or not_subscribed, invalid_action or unknown_action when there was nothing to run.
A message without an id gets no reply. What would have been an error reply is an error frame instead, with the reason (and the data of an ActionError) at the top level; an action that raises anything else sends nothing. An error also reports a command the server couldn't read: invalid_json, invalid_message (JSON that is not an object), or unknown_command.
The confirmation's presence lists who is in each stream the channel tracks, {key: data}. The presence_join, presence_leave and presence_update frames go to every subscriber of the stream (topic), when a user's first connection is tracked, their last one leaves, or their data changes; they may arrive before the confirmation of the subscription that caused them.
The ping is the cable's, every CABLE_PING_INTERVAL seconds. The client answers it with {"c": "WSE", "t": "PONG", "p": {}}; a connection that answers none for idle_timeout seconds is closed. cable.js also uses it to tell a dead connection from a quiet one: three intervals of silence (and at least 10 seconds), and it reconnects.
18. A full example
A room chat: an authenticated channel, a controller that saves a message and broadcasts it, and the page that ties them together.
from proper import current
from ..models import Room
from ..router import router
from .app_channel import AppChannel
@router.channel()
class ChatChannel(AppChannel):
def subscribed(self):
room = Room.get_or_none(Room.id == self.params["room_id"])
if (
room is None
or not self.authenticated
or not room.has_member(current.user)
):
self.reject()
return
self.stream_from(f"chat_{room.id}")
from proper import current, turbo_stream
from ..models import Message
from ..router import router
from .app_controller import AppController
@router.resource("rooms/:room_id/messages")
class MessageController(AppController):
def create(self):
room_id = self.params["room_id"]
message = Message.create(
room_id=room_id,
author=current.user,
text=self.params["text"],
)
self.app.cable.broadcast(
f"chat_{room_id}",
turbo_stream.append("messages", "message.jx", message=message),
)
self.response.redirect_to("Message.index", room_id=room_id)
{#def room, messages #}
{#import "message.jx" as Message #}
<turbo-stream-channel channel="ChatChannel" params='{"room_id": {{ room.id }}}'>
</turbo-stream-channel>
<ul id="messages">
{% for message in messages %}
<Message message={{ message }} />
{% endfor %}
</ul>
<form method="post" action="{{ url_for('Message.create', room_id=room.id) }}">
<input name="text" autocomplete="off">
<button>Send</button>
</form>
The channel guards the room with the server-verified user. The controller saves the message and broadcasts it, rendered with the same component as the list. Every open page of the room, including the sender's, gets the new message, and there is no JavaScript of your own.
19. Where this could grow
Channels covers the core - one shared connection, authenticated subscriptions, streams, broadcasting, a reconnecting client, recovery of missed broadcasts, replies to actions, and Turbo Streams. Some things that other real-time stacks offer are not here yet. None of them block you - there are workarounds - but they are where the framework will likely grow.
- Stream names from models.
broadcast_to(record, data)andstream_for(record), which would build the stream name from a model, so a typo can't break it silently. - Per-subscription timers. A
periodically(...)hook for server-driven updates - counters, clocks, dashboards - that lives as long as the subscription. For now, a periodic background task that broadcasts to a stream covers most of this.
20. What's next
Channels touches several other parts of Proper:
- Authentication - the session and signed cookie that
AppChanneluses to putcurrent.useron a connection. - Turbo - Turbo Frames and Streams, also as responses to forms.
- Background Tasks - the worker behind broadcasting from a task, plus scheduling and retries.
- Jx Components - the components you render into a
<turbo-stream>to broadcast. - Deployment - processes, and the cable port behind nginx.