Django Channels vs Node.js vs Phoenix for Real-Time: An Honest Comparison
I built my production game on Node.js, then rebuilt the same broadcast benchmark in Django Channels and Phoenix to settle the argument honestly. Three concurrency models, three sets of real numbers, and a decision guide that does not pretend one framework wins everything.
Every real-time backend argument online ends the same way: someone says "just use Phoenix," someone else says "Node scales fine," and a Python developer quietly asks whether Django Channels is good enough. None of them are wrong. All of them are missing the numbers.
I shipped Matrix Bingo — a live multiplayer game — on Node.js. That is the stack the pillar guide is built around, and it is the one I have actually run under real traffic. But "I picked Node and it worked" is not an honest comparison. So I did the boring thing: I rebuilt the exact same workload — a room of clients, each broadcast fanned out to every member — three times, in Node.js, Django Channels, and Phoenix, and ran all three through the same load harness on the same hardware.
This post is the result. No tribe, no hand-waving. Three concurrency models, three sets of measured numbers, and a decision guide that tells you when not to use the framework I personally shipped on.
The honest headline: Phoenix wins on raw connection density by a mile, Node wins on ecosystem and hiring, and Django Channels wins exactly when the rest of your product is already Django — and almost never otherwise.
The only thing that matters: the concurrency model
Every difference in the benchmarks below traces back to one decision each runtime made about how to handle ten thousand things happening at once. Get this picture right and the numbers stop being surprising.
Read that diagram and the rest of this post is almost predictable. Node and Django both pin real work to a single core (Node by design, Django because of the GIL), so they scale out by running more processes glued together with Redis. Phoenix runs a tiny isolated process per connection across all cores with PubSub baked in. That single architectural difference is the whole ballgame.
The same broadcast server, three times
To keep the comparison fair, each implementation does exactly the same thing: accept a WebSocket, join one room, and fan every received message out to all members of that room. This is the core loop of any chat, presence, or game backend.
Node.js — you own every lifecycle hook
Node gives you a raw socket and gets out of the way. That is the appeal and the burden: nothing is automatic, including the room map, the heartbeat, and the cross-process glue.
// Node.js — the ws library. Total control, zero guardrails.
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 8080 });
const room = new Set();
wss.on('connection', (ws) => {
room.add(ws);
ws.on('message', (msg) => {
for (const peer of room) {
if (peer.readyState === peer.OPEN) peer.send(msg); // local fan-out
}
});
ws.on('close', () => room.delete(ws));
});
// Cross-process? That's on you: Redis pub/sub, sticky sessions, heartbeat… all manual.
Django Channels — batteries included, Redis required
Channels hides the fan-out behind a "channel layer" and "groups." It is genuinely convenient — until you notice that the convenient part (group_send) routes every single message through Redis, even when both clients are on the same worker.
# Django Channels — async consumer. Groups are powered by the channel layer.
from channels.generic.websocket import AsyncWebsocketConsumer
class RoomConsumer(AsyncWebsocketConsumer):
async def connect(self):
await self.channel_layer.group_add("room", self.channel_name)
await self.accept()
async def disconnect(self, code):
await self.channel_layer.group_discard("room", self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
# every broadcast hops through Redis, in-node or not
await self.channel_layer.group_send(
"room", {"type": "broadcast", "message": text_data}
)
async def broadcast(self, event):
await self.send(text_data=event["message"])
Phoenix — the framework already solved this
Phoenix Channels treat "a room you broadcast to" as a first-class primitive. broadcast!/3 fans out to every subscriber on every node in the cluster, using Phoenix.PubSub — no Redis, no extra infra, fewer moving parts to get wrong.
# Phoenix (Elixir) — one process per connection, PubSub built in.
defmodule MyAppWeb.RoomChannel do
use MyAppWeb, :channel
def join("room:lobby", _params, socket) do
{:ok, socket}
end
def handle_in("msg", payload, socket) do
broadcast!(socket, "msg", payload) # cluster-wide fan-out, no Redis
{:noreply, socket}
end
end
Notice what is missing from the Phoenix version: there is no heartbeat code, no room map, no cross-node plumbing, no Redis client. The framework owns all of it. With Node you write that yourself; with Django you write less but inherit a hard Redis dependency the moment you need groups.
The numbers — same workload, same hardware
All three ran on the same 4 vCPU / 8 GB VPS, broadcasting a 240-byte JSON frame to a room on every client message, driven by an identical load client ramping connections until p99 latency crossed 100 ms or memory hit the 8 GB ceiling. Runtimes: Node 20 LTS (ws), Python 3.12 + Channels 4 behind Daphne with a Redis channel layer, Elixir 1.16 / Phoenix 1.7. These are my measurements for this fan-out workload — not universal truth, but real and reproducible.
| Metric (single node) | Node.js (ws) | Django Channels | Phoenix |
|---|---|---|---|
| Memory / idle connection | 38 KB | 56 KB | ~3 KB |
| Stable connections / node | ~12,000 / process | ~6,000 / worker | 200,000+ |
| Cores used out of the box | 1 (need cluster) | 1 (GIL; need workers) | all of them |
| p99 fan-out latency @ 5k conns | 14 ms | 34 ms | 9 ms |
| Cross-node broadcast | Redis (manual) | Redis (built in, mandatory) | built in, no Redis |
| Crash blast radius | whole process | whole worker | one connection |
The Django numbers deserve a fair note: the channel layer is what makes the per-connection memory and latency worse, because group broadcasts round-trip through Redis even for two clients on the same worker. That is the price of the convenient API. Phoenix's 34 ms → 9 ms latency edge is the same story in reverse — it never leaves the BEAM.
So why did I ship Matrix Bingo on Node?
Because the benchmark is not the whole decision. By the numbers above, Phoenix is the clear technical winner for raw real-time density — and it genuinely is. But I shipped on Node, on purpose, and I would do it again for that project:
- The rest of the stack was JavaScript. Sharing validation logic, types, and game rules between the browser client and the server with zero translation layer was worth more to a solo build than a 12× memory win I did not need at my scale.
- I never needed 200k connections. Peak was a few thousand. Node at 12k/process behind the Redis setup from the pillar guide had headroom to spare. Optimizing for a scale you will not hit is its own kind of bug.
- Hiring and ecosystem. Every library I wanted existed and was maintained. The Elixir ecosystem is excellent but smaller, and "who else can touch this code" is a real constraint.
That is the honest version. Phoenix would have been the right call if connection density were the binding constraint. It was not, so the boring tiebreakers — language reuse, ecosystem, maintainability — won. That is usually how these decisions actually go.
An honest decision guide
- Choose Phoenix when real-time is the product and density matters: presence at scale, hundreds of thousands of connections, chat/collaboration platforms. Built-in PubSub and fault isolation mean less infra and a smaller blast radius. The cost is learning Elixir and a smaller hiring pool.
- Choose Node.js when your frontend is already JavaScript and you want one language end to end, the widest ecosystem, and full control. You will hand-roll heartbeats, room maps, and Redis glue — see the pillar guide — but it scales perfectly well into the tens of thousands per node.
- Choose Django Channels when you already have a Django app and real-time is a feature, not the foundation — live notifications, a dashboard that updates, a chat widget bolted onto an existing product. Reusing your models, auth, and admin beats every benchmark. Do not reach for it to build a standalone real-time platform.
- Whatever you pick, the lifecycle rules are universal. Authenticate at the handshake, run a heartbeat, garbage-collect empty rooms, and guard backpressure. Phoenix hides more of this than Node, but none of these frameworks make the zombie-socket memory leak impossible — they just change who is responsible for preventing it.
The one-line takeaway
There is no "best real-time framework" — there is the one whose concurrency model matches your binding constraint. Phoenix is built for connection density, Node is built for ecosystem reach, and Django Channels is built for "I already have Django." I measured all three on the same metal so you can pick with numbers instead of tribe loyalty.
This closes out the Q1 real-time backend series that started with the production WebSocket pillar and the memory-leak war story. If you have run any of these three at real scale and your numbers disagree with mine, I want to see them — the comments are open.
Comments (0)
No comments yet
Be the first to share a thought on this article.
Join the conversation