Real-time WebSockets in Django: Channels, Pusher, or Vask
You have a Django app and you want real-time. A notifications panel that refreshes without a page load. A presence indicator on a shared document. A live feed. The pattern is broadcasts and subscriptions, and the question is which substrate carries them.
The Django ecosystem gives you three honest options in 2026. This page names them, explains the trade-offs without spin, and shows the code for the one that uses the Pusher Channels protocol.
Get started
Realtime made simple.
Free Tier: 500K broadcasts/mo and 100 concurrent across unlimited apps. $10/mo when you outgrow it.
Option 1: Django Channels (ASGI)
Django Channels is the WebSocket extension maintained as part of the Django project's extended ecosystem. It replaces Django's WSGI entry point with an ASGI one, adds a URLRouter for WebSocket paths, and lets you write consumers that handle connection lifecycle and message handling the same way class-based views handle HTTP requests.
Official docs: Django Channels.
What it gives you:
- An open-source, protocol-agnostic WebSocket layer in the Django ecosystem. You own the consumer code, the message format, and the protocol shape.
- Full Django ORM access inside consumers. Presence tracking, message history, per-user state: all queryable against your existing models.
- No external billing. The cost is the ASGI workers and a Redis (or in-memory) channel layer.
- The ability to mix HTTP and WebSocket traffic in a single ASGI app under one deployment unit.
When it is the right call:
- Your project already runs ASGI, or the team is ready to make that move. Daphne, Uvicorn, and Hypercorn all support it; a Django Channels migration is a
settings.pyandasgi.pychange. - You want full control over the WebSocket protocol and message shape. Custom binary frames, GraphQL subscriptions, custom presence logic: Channels does not prescribe any of it.
- You prefer no per-message or per-connection billing and you are comfortable operating Redis (or the in-memory layer for low-traffic use).
- Local development for any Django project, including projects that may run a hosted service in production.
When you'd reach for something else:
- You want to ship multi-region without operating a multi-region ASGI fleet yourself.
- You do not want to manage a channel layer backend (Redis) alongside your application.
- Your traffic profile has grown to where scaling ASGI workers and the channel layer is a project, not a config knob.
If Channels fits, use Channels. The remainder of this page is for teams that want the Pusher Channels protocol on a managed service, either because the ASGI operating model is not the right fit or because the bill on hosted Pusher has grown.
Option 2: Hosted Pusher Channels (the incumbent)
Pusher Channels is the original hosted Pusher protocol service. The SDK ecosystem is mature (pusher-http-python, pusher-js, official SDKs in every major language), the documentation is thorough, and for years it was the default answer for Django apps that wanted real-time without running their own WebSocket server.
What it gives you:
- A fully managed hosted service. Point
pusher-http-pythonat the Pusher cluster you chose at app-creation time and it works. - Private channels, presence channels, and channel auth via a standard auth endpoint in your Django views.
- Debug consoles, connection counts, and message throughput visible in the Pusher dashboard without instrumenting anything yourself.
The thing nobody warns you about until you cross a threshold:
The bill scales with the number of subscribers per channel, not with the number of publishes. The category has a name: fan-out, and the surcharge is the fan-out tax. One publish to a channel with 200 subscribers is 201 billable messages on the standard pricing model. A presence channel with 500 connected users turns a single status broadcast into 501 messages on the bill. High-frequency events like typing indicators compound quickly.
This is the priced unit, not a quirk. The model dates from an era when connections were scarce and publishes were rare. Modern apps fan out constantly.
When it is still a reasonable call:
- You are already on Pusher, the bill is within budget, and the integration is stable. Switching costs migration time.
- Your traffic pattern is broadcast-light per subscriber, which the per-message model handles without issue.
If your fan-out factor is high or growing, the calculator on /compare/pusher-vs-vask shows what the per-fan-out multiplier is doing to your specific bill.
Option 3: Vask (hosted Pusher protocol on Cloudflare's edge)
Vask is a managed Pusher-protocol WebSocket service running on Cloudflare's edge network, billed per broadcast rather than per fan-out copy, with connections terminating at the closest of 330+ edge cities.
What it gives you:
- The Pusher Channels protocol on the wire, kept on purpose. Your
pusher-http-pythonserver code, yourpusher-jsclient, your auth views, your private and presence channel naming: all unchanged. - Broadcast-priced billing. One publish is one event on the bill regardless of how many subscribers are on the channel. No fan-out multiplier. No presence-channel surcharge.
- Connections that terminate on Cloudflare's edge network.
- Drop-in compatibility. If you are already on a Pusher-protocol service, the cutover is a credentials change in your environment config, not a code change.
When it is the right call:
- You are on hosted Pusher, the bill is dominated by fan-out, and the math has stopped working.
- You want multi-region edge presence without operating a multi-region fleet.
- You want hosted Pusher protocol without running ASGI workers, a channel layer, and Redis.
When it is not the right call:
- Channels fits, and the answer is Channels. (Worth repeating.)
- You are below the free tier on your current service and the bill is zero. Switch when the bill appears, not before.
- Your real-time feature does not fit the Pusher Channels protocol (WebRTC media, MQTT-shaped IoT messaging, a proprietary frame format).
If you are migrating off hosted Pusher specifically, the step-by-step Django recipe is at /migrate/pusher-to-vask-django. The head-to-head comparison with calculator is at /compare/pusher-vs-vask.
What the Django code actually looks like
Options 2 and 3 share the same wire protocol, so the application code is identical between them. Only the credentials change.
Server-side client singleton
myproject/pusher_client.py:
import pusher
from django.conf import settings
pusher_client = pusher.Pusher(
app_id=settings.PUSHER_APP_ID,
key=settings.PUSHER_APP_KEY,
secret=settings.PUSHER_APP_SECRET,
host=settings.PUSHER_HOST,
ssl=True,
)Import this singleton in views or Celery tasks rather than instantiating a new client per request.
Publishing from a view or task
myapp/tasks.py:
from myproject.pusher_client import pusher_client
def notify_order_shipped(order_id: int, user_id: int) -> None:
pusher_client.trigger(
f"private-orders.{user_id}",
"order.shipped",
{"order_id": order_id},
)pusher_client.trigger sends the event. Same call, same result, against Pusher or Vask.
Channel auth view
myapp/views.py:
import json
from django.http import HttpResponse, HttpResponseForbidden
from django.views.decorators.http import require_POST
from myproject.pusher_client import pusher_client
@require_POST
def pusher_auth(request):
if not request.user.is_authenticated:
return HttpResponseForbidden()
channel_name = request.POST.get("channel_name", "")
socket_id = request.POST.get("socket_id", "")
# For presence channels, return user info as the auth payload.
if channel_name.startswith("presence-"):
auth = pusher_client.authenticate(
channel=channel_name,
socket_id=socket_id,
custom_data={"user_id": request.user.pk, "user_info": {"name": request.user.get_full_name()}},
)
else:
auth = pusher_client.authenticate(channel=channel_name, socket_id=socket_id)
return HttpResponse(json.dumps(auth), content_type="application/json")Wire it up in urls.py:
from myapp.views import pusher_auth
urlpatterns = [
path("pusher/auth/", pusher_auth, name="pusher_auth"),
]The view is decorated with @require_POST. Django's CSRF middleware applies by default. The client must forward the CSRF token so the auth request passes validation.
CSRF trade-off. Adding @csrf_exempt removes the CSRF check and is simpler to configure. The risk is that the auth endpoint becomes callable cross-origin without the browser's CSRF protection. The safer approach is forwarding the token: two extra lines in the client. Prefer forwarding the token unless the project already disables CSRF middleware globally.
Client setup (pusher-js)
Works with Vite, Webpack, django-vite, or a CDN script tag. The pusher-js package does not depend on the bundler, but the way you expose browser-safe config does. In a Django template, render a small public config object rather than reading process.env in the browser.
import Pusher from 'pusher-js';
const config = window.VASK_PUSHER;
const pusher = new Pusher(config.key, {
cluster: config.cluster || 'mt1',
wsHost: config.host || 'wss.vask.dev',
wssPort: 443,
forceTLS: true,
authEndpoint: '/pusher/auth/',
auth: {
headers: {
// Forward the Django CSRF token on the auth POST.
'X-CSRFToken':
document.cookie.match(/csrftoken=([^;]+)/)?.[1] ?? '',
},
},
});
const channel = pusher.subscribe('private-orders.' + userId);
channel.bind('order.shipped', (data) => {
console.log('Order shipped:', data.order_id);
});The X-CSRFToken header satisfies Django's CSRF middleware without @csrf_exempt.
Picking between the three: a flowchart
Do you have a Django app?
|
YES
|
Is Channels over ASGI a viable operating model?
(team owns ASGI workers + Redis channel layer)
|
┌────────────┴────────────┐
YES NO
| |
CHANNELS. Are you OK with the
Done. per-fan-out billing
model on hosted Pusher?
|
┌───────────┴───────────┐
YES NO
| |
PUSHER. VASK.
Done. Done.Three honest answers. Options 2 and 3 share the same client code and server SDK. The decision is operating model and bill shape.
When NOT to switch from Channels
If you are running Django Channels in ASGI, the concurrency fits, and the team is comfortable with the Redis channel layer, stay on Channels. Vask is not the "next level" from Channels. It is a different answer for a different shape of project.
The cases where moving to a hosted Pusher-protocol service actually makes sense:
- You have outgrown a single-region operating model and your users are in multiple continents.
- You no longer want to operate ASGI workers, a Redis channel layer, and the supervision around them.
- Your concurrency is high enough that scaling the channel layer is a project, not a config change.
None of those are about Channels being a bad tool. They are about the project changing shape. If your project has not changed shape, do not migrate.
- What's the simplest way to add WebSockets to a Django app in 2026?
- If your Django project already runs on ASGI (using Daphne, Uvicorn, or Hypercorn), Django Channels is the canonical Django-ecosystem answer. You install channels, configure an ASGI application with a URLRouter, write a WebSocket consumer, and you have WebSockets without adding a hosted vendor. For teams comfortable running ASGI workers and a channel layer, Channels is the right answer.
- When does a hosted WebSocket service make sense over Django Channels?
- Three scenarios. First, you do not want to operate an ASGI stack, a channel layer backend, and the Redis that backs it. Second, your users are spread across multiple regions and you want connections to terminate at the closest edge rather than a single home region. Third, your concurrency or broadcast volume has grown to where scaling ASGI workers and the Redis channel layer is becoming a project. Outside those cases, in-process Channels is the smaller, more self-contained answer.
- How do I authenticate private channels in Django without using a full Channels setup?
- With pusher-http-python you add an /pusher/auth/ view that receives the POST from pusher-js, verifies the user's session (or token), and calls pusher.authenticate(channel_name, socket_id). The view is decorated with @require_POST and the client forwards the Django CSRF token via X-CSRFToken in the auth headers. A trade-off exists: @csrf_exempt is simpler to set up but removes CSRF protection on that endpoint; forwarding the CSRF token is two extra lines in the client and keeps the endpoint protected.
- How is Vask different from Pusher in a Django app?
- Both speak the Pusher Channels protocol on the wire, so pusher-http-python on your server and pusher-js on the client work identically against either. The differences are where connections terminate and how the bill is calculated: Vask bills per broadcast, while per-message models multiply by subscriber count. Use your own fan-out factor to compare the bill.
- Does the Django frontend pipeline matter? Webpack vs Vite vs django-vite?
- The pusher-js client is a plain npm package. It works with any pipeline: import Pusher from 'pusher-js' in a Vite-managed entrypoint, require() it in a Webpack bundle, or load it from a CDN in a Django template tag. django-vite just resolves the manifest and injects the script tags; pusher-js does not know or care about the bundler. Pick whichever frontend pipeline your project already uses.
- Can I run Django Channels locally and Vask in production?
- Channels and Vask do not share a wire protocol, so the client code differs between the two. Channels typically uses a custom Django consumer and a plain WebSocket client or channels-specific client library. Vask (and Pusher) use pusher-js. If you want the same client code between environments, configure Vask credentials in both local and production env files rather than mixing Channels and Vask.
Get going
If you want Channels, the Django Channels documentation is the canonical reference. If you want hosted Pusher protocol on Cloudflare's edge with broadcast-priced billing, the recipe below gets you there.
Hosted Pusher protocol on the edge
Same Django code. Different substrate.
Drop in your credentials, keep pusher-http-python and pusher-js. Most Django teams ship the integration in under fifteen minutes.