Real-time without the polling: WebSockets with Django Channels
Most of what we build on the web is request/response: the browser asks, the server answers, the connection closes. It’s the model Django was built around, and it has one stubborn limitation: the server can never speak first. If something happens that a user should see right now (a chat message from someone else, a job finishing, a price ticking), plain HTTP has no way to tell the browser. The browser has to ask.
The classic workaround is to keep asking: poll the server every few seconds and hope the news is fresh. That’s wasteful, slow, and never feels live. This post is about the alternative, a persistent two-way connection, and how Django Channels bolts it onto an otherwise ordinary Django app.
Why request/response can’t push
Django can’t push because of the interface underneath it. Classic Django runs under WSGI: as the ASGI docs put it, “a single, synchronous callable that takes a request and returns a response.” One request in, one response out, done. There is no place in that contract for the server to hold a line open and speak again later; the ASGI introduction says plainly that the “single-callable interface just isn’t suitable for more involved Web protocols like WebSocket.”
So if you want push, you poll, and polling’s latency is structural: an event that happens just after a poll waits for the next one before anyone sees it. Crank the interval down and you flood the server with mostly-empty requests; crank it up and the app feels sluggish.
ASGI, the Asynchronous Server Gateway Interface, is the “spiritual successor to WSGI” that reshapes the contract: an application is an async callable handed a scope (what kind of connection is this), a receive (pull the next incoming event), and a send (push one out). Many events, both directions, over one long-lived connection: exactly what a WebSocket needs. Django can be deployed under ASGI, and Channels is the library that builds WebSockets on top of that.
Consumers and the channel layer
Channels gives you two pieces. The consumer is the WebSocket equivalent of a view: you subclass AsyncWebsocketConsumer and fill in connect, receive (one call per frame), and disconnect. The consumers docs describe the design as structuring your code “as a series of functions to be called whenever an event happens, rather than making you write an event loop.”
The channel layer is a shared message bus, usually Redis, that lets consumers in different processes and on different machines talk to each other. This is plain publish/subscribe: a consumer subscribes a connection to a named group, and anything published to the group fans out to every subscriber. One browser’s message arrives at one consumer instance; for every other browser to see it, that consumer has to publish it through the layer.
Here’s the connect method from a chatbot I built. When a socket opens, it accepts the connection and joins a named group, so it’s now a subscriber to anything broadcast there:
class AsyncChatConsumer(AsyncWebsocketConsumer):
group_name = CHAT_GROUP_NAME
async def connect(self):
await self.accept()
await self.channel_layer.group_add(
self.group_name, self.channel_name
)
# ... load history and greet the client ...
async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.group_name, self.channel_name
)
self.channel_name is this specific connection’s unique address; group_add adds it to the group. The symmetry matters: whatever you add in connect, you remove in disconnect with group_discard, or you leak dead connections into the group and keep trying to send to sockets that closed long ago.
Broadcasting to a group
The payoff is in receive. When a client sends a message, the consumer doesn’t reply directly. It hands the message to the channel layer with group_send, and the layer delivers it to every member of the group:
async def receive(self, text_data):
data = json.loads(text_data)
response = "FAILED"
model = ''
if "chat" in data:
message = data["chat"]["message"]
model = data["chat"]["model"]
response = await self.chat.ask(message, model=model)
await self.channel_layer.group_send(
self.group_name, {
"type": "notify.client",
"data": {
"response": {"message": str(response)},
"model": model if model else 'Bot',
},
}
)
async def notify_client(self, event):
await self.send(json.dumps({"chat": event["data"]}))
The clever bit is "type": "notify.client". The layer delivers that event to each consumer in the group, and the consumer’s dispatcher swaps the dot for an underscore and calls the method of that name, notify_client, on its own instance. Each consumer then does its own self.send(...) over its own socket: one inbound message, an outbound message to everyone connected. It’s also why you can’t just await self.send() from inside receive; that would answer only the one client who spoke. The round-trip through the layer is what reaches the rest.
On the browser side there’s nothing exotic; the native WebSocket API is a few lines:
const socket = new WebSocket(`wss://${location.host}/ws/chat/${uuid}/`);
socket.addEventListener("open", () => {
socket.send(JSON.stringify({ chat: { message: "hello", model: "gpt" } }));
});
socket.addEventListener("message", (event) => {
const { chat } = JSON.parse(event.data);
renderMessage(chat); // the server pushed this; no request was made
});
That message handler fires whenever the server pushes, with no request on the client’s part.
The sync/async ORM boundary
There’s a trap waiting inside every async consumer. Your consumer methods are async, but Django’s ORM is not. The Channels database docs are blunt about it: “The Django ORM is a synchronous piece of code,” and calling it directly from async code can corrupt connection state. Django’s own ASGI guide repeats the rule: “Do not call blocking synchronous functions or libraries in any async code.”
The fix is database_sync_to_async, “a version of asgiref.sync.sync_to_async that also cleans up database connections” when it’s done. Wrap any ORM access in it, as a decorator or inline, and it runs the query in a thread and hands the result back to your coroutine:
from channels.db import database_sync_to_async
@database_sync_to_async
def get_chat(self):
return ContextChat.objects.get(
uuid=self.scope["url_route"]["kwargs"]["uuid"]
)
Now await self.get_chat() is safe inside connect or receive. The mental model: the consumer lives in the async world, the ORM in the sync world, and database_sync_to_async is the one door between them. Use it every time you touch the database.
Measuring push vs. poll
So how much does pushing actually buy you? I measured it. The setup: this consumer pattern served by daphne, 300 events fired at random moments, one WebSocket client that receives each event as a push, and one HTTP client that polls for news every 3 seconds. Everything on one machine, so a single clock times both sides and the wire cost is a loopback hop. What matters is the whole distribution, not the average, the same lens I used keeping LLM triage under a second.
Push delivered all 300 events in under 2 ms, median 0.5 ms: one network hop. Polling’s median was 1.5 s, and its deliveries smear from a lucky 8 ms (the event landed just before a poll) to the full 3 s (it landed just after one). The wait is uniform across the interval, which is why the median sits near half of it. On a real network both curves shift right by the same WAN hop, tens of milliseconds, and the three-orders-of-magnitude gap survives: polling’s latency is dominated by waiting for permission to ask, and that is precisely the cost a persistent connection deletes.
A persistent connection isn’t free, though. The browser WebSocket API has no backpressure: if the server pushes faster than the client drains, messages pile up in a buffer and memory grows. My measurement demonstrated this by accident: the emitting client also sat in the broadcast group, never read the echoes coming back, and once its receive queue filled its library stopped processing frames, keepalive pongs included, and the connection died mid-run. The server side has its own version of the problem: each channel has a capacity, and “trying to send() to that channel may raise ChannelFull.” Group sends never raise; the layer must “silently drop the message if it is over capacity,” following ASGI’s at-most-once delivery policy. So broadcasting is lossy under pressure by design, which is fine for a chat ping and very much not fine for a financial ledger.
Where I used this
I built exactly this on a Django chatbot project: an AsyncWebsocketConsumer, a single Redis-backed group, and database_sync_to_async around every ORM call so loading chat history and saving messages never blocked the event loop. The result is a chat that feels instant and stays in sync across tabs and users: no polling, no “refresh to see new messages”, just the server speaking the moment it has something to say. The socket is only the transport, of course; making the bot on the other end worth talking to is its own problem, and I’ve written about one slice of it in giving an LLM agent memory.
The pieces generalize well beyond chat. Any time the server knows something before the client could think to ask (a notification, a dashboard tile, a sandboxed run of model-written code finishing), the same three moves apply: a consumer that joins a group on connect, a group_send to fan the event out, and a disciplined sync/async boundary around the database. Get those right and the rest is ordinary Django.
If you’re running something real-time on Django in production, I’m curious which route you took: Channels, server-sent events, or an external push service, and what tipped the decision.
References
- Django Channels — Consumers — the
connect/receive/disconnectlifecycle and the event-driven model. - Django Channels — Channel Layers —
group_add,group_send,group_discard, and the Redis backend for production. - Django Channels — Channel Layer Specification — channel capacity,
ChannelFull, and the at-most-once policy for group sends. - Django Channels — Database access — why the synchronous ORM is unsafe in async code and what
database_sync_to_asyncdoes. - ASGI — Introduction — ASGI as the successor to WSGI and why WSGI can’t do WebSocket.
- ASGI 3.0 Specification — the
scope/receive/sendinterface and send-buffer semantics. - Django — How to deploy with ASGI — the
applicationcallable and the async-safety warning. - PEP 3333 — WSGI — the single synchronous callable ASGI was designed to move past.
- MDN — WebSocket API — the browser client: constructor,
messageevent,send(), and the no-backpressure caveat.
Working on something in this space, or hiring for it?
Keep reading
- One flexible API for many shapes: polymorphic vaults in Django REST FrameworkStoring recipes, bookmarks, journals and groceries behind a single REST surface: a polymorphic Item base, dynamically composed nested serializers, atomic multi-table writes, and the eager-loading that kills the N+1 queries.July 9, 2026
- Write Once, Talk to Any LLM: a Provider-Agnostic AbstractionEvery LLM provider has its own message shape, token counting, and errors. Here's how to hide all of it behind one Chatter interface, using mixin composition instead of factory boilerplate.July 21, 2026
- Keeping a long chat in the window: pruning by summarization, not deletionWhen a conversation overflows the context window, dropping the oldest turns throws away meaning. Summarize the middle instead: the pattern every major LLM provider now ships natively, and how the loop works when you build it yourself.July 15, 2026
Get the next update by email
Build-in-public updates and new posts, delivered as a digest. Double opt-in · no spam · unsubscribe anytime · handled by Buttondown.