← Back to Studio Notes

Real-Time Data Streaming with WebSockets in Django & Vue

Learn how to build real-time collaborative dashboards, notifications, and live chat using Django Channels, Redis channel layers, and Vue 3 composables.

KEHEM / Studio Notes
  KEHEM

Modern business applications require real-time feedback.

When a team member updates a project status, a customer places an urgent order, or a sensor triggers an operational alert, users should see the changes instantly—without constantly refreshing their browser.

While traditional HTTP polling (setInterval hitting an API every 5 seconds) works for small prototypes, it overwhelms database servers as traffic scales.

WebSockets with Django Channels and Redis provide true bi-directional, low-latency communication.

The honest framing, though: most business dashboards do not need WebSockets. They need fast enough updates, and the cheapest route to "fast enough" is often a 30-second poll with proper caching. WebSockets earn their place when latency is genuinely part of the product — collaboration, chat, live operations — and the operational cost that comes with long-lived connections is worth paying. This guide covers both the architecture and the decision.

The Architecture of Real-Time Django

Vue 3 Client ◄──(WebSocket Connection)──► Django Channels (ASGI)
                                                  │
                                                  ▼
                                         Redis Channel Layer
                                    (Pub/Sub Event Distribution)

3 Core Components of Real-Time Apps

  1. ASGI Server (Daphne/Uvicorn): Handles concurrent, long-lived WebSocket connections without blocking HTTP threads.
  2. Redis Channel Layer: Acts as the message broker that allows different workers and background Celery tasks to broadcast messages to connected users.
  3. Vue 3 WebSocket Composable: Manages connection lifecycle, auto-reconnect logic on network dropouts, and JWT token authentication.

Choosing the Transport

TransportDirectionEffortBest forWatch out for
Short pollingClient → serverLowestDashboards updating every 30s+Wasted queries; scaling cost
Long pollingClient → serverLowLegacy environmentsConnection churn
SSE (Server-Sent Events)Server → clientLowNotifications, feeds, progress barsOne-way only; 6-connection limit per domain on HTTP/1.1
WebSocketsBidirectionalHighestChat, collaboration, live opsConnection state, auth, scaling, proxies
Webhooks (server→server)Server → serverLowExternal integrationsNot a browser channel

If your updates only flow one way, SSE is usually the better engineering choice: it runs over plain HTTP, reconnects automatically, needs no channel layer, and passes through almost every corporate proxy. WebSockets are for when the client also needs to push.

Authentication: The Detail Most Tutorials Get Wrong

Passing a JWT in the query string (wss://host/ws/?token=...) is the pattern almost every tutorial shows and the one we most often have to remove. Query strings land in server logs, proxy logs, monitoring tools and browser history. Use one of these instead:

  • Session cookies (httpOnly, Secure, SameSite), validated in AuthMiddlewareStack, for same-origin browser clients. Simplest and safest for a standard Django + Nuxt setup.
  • A short-lived ticket: the authenticated client requests a one-time token (valid 30–60 seconds, single use) from a normal HTTP endpoint, then presents it in the first WebSocket message. Useful for cross-origin or mobile clients.

Then authorise every subscription individually. A client joining project_42_updates must be checked against that project's permissions, and the tenant must come from the server-side session — never from a tenant ID sent by the client.

class ProjectConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        self.project_id = self.scope["url_route"]["kwargs"]["project_id"]
        user = self.scope["user"]
        if not user.is_authenticated or not await can_view_project(user, self.project_id):
            await self.close(code=4403)          # custom close code for denied
            return
        self.group = f"project_{self.project_id}"
        await self.channel_layer.group_add(self.group, self.channel_name)
        await self.accept()

    async def disconnect(self, code):
        if hasattr(self, "group"):
            await self.channel_layer.group_discard(self.group, self.channel_name)

Delivery Guarantees, and Why You Need a Catch-Up Path

Redis pub/sub is at-most-once: a message published while a client is offline is gone. Real-time dashboards that only receive deltas therefore drift out of sync the moment a laptop sleeps or a phone switches networks.

The fix is a hybrid:

  1. Persist events in a table (id, tenant_id, project_id, type, payload, created_at).
  2. Publish the event ID over the channel layer after the transaction commits.
  3. On connect or reconnect, the client sends its last seen event ID and the server replays everything newer.
  4. Make client-side application idempotent, so a duplicate event is a no-op.

That design turns "the socket dropped" from a data-integrity problem into a brief delay. It also gives you an audit trail of what changed, which any B2B customer will eventually ask for.

Use transaction.on_commit() when publishing. Publishing inside a transaction that later rolls back creates phantom events in the UI — a confusing bug with an obvious fix.

Reconnection Done Properly

  • Exponential backoff with jitter (1s, 2s, 4s, 8s… capped at 30s, with randomisation) so a server restart does not produce a synchronised stampede of clients.
  • Do not reconnect on authentication failure. A 4403 close means the user has no access; retrying makes noise and hides the real problem. Surface it in the UI instead.
  • Show connection state to the user. A discreet "reconnecting…" indicator prevents people making decisions from stale data — a genuinely important detail for operations dashboards.
  • Pause when the tab is hidden, using the Page Visibility API, and reconnect on focus. It saves a large amount of server capacity for users with many tabs open.

Scaling and Operational Reality

ConcernWhat to do
Horizontal scalingChannel layer handles cross-worker delivery; no sticky sessions required
Proxy configurationNginx needs Upgrade/Connection headers and a read timeout above your ping interval
File descriptor limitsEach socket is a connection — raise nofile on the host before you need to
Idle connectionsSend a heartbeat ping every 30–60s; idle NAT timeouts silently kill sockets
Message sizeSend references and fetch details over HTTP; large payloads over sockets cause head-of-line blocking
BackpressureCap messages per client per second, and drop or coalesce low-priority updates
MonitoringTrack connection count, reconnects, message rate and publish-to-delivery latency

The Vue 3 Side

A single composable, used by any component that needs live data:

export function useLiveChannel(path, onMessage) {
  const status = ref('connecting')
  let socket, attempt = 0, timer

  function connect() {
    socket = new WebSocket(`${WS_BASE}${path}`)
    socket.onopen = () => { status.value = 'open'; attempt = 0 }
    socket.onmessage = (e) => onMessage(JSON.parse(e.data))
    socket.onclose = (e) => {
      status.value = 'closed'
      if (e.code === 4403) return                 // no access — do not retry
      const delay = Math.min(30000, 1000 * 2 ** attempt++) + Math.random() * 1000
      timer = setTimeout(connect, delay)
    }
  }

  onMounted(connect)
  onUnmounted(() => { clearTimeout(timer); socket?.close(1000) })
  return { status }
}

Expose status in the UI, and clean up on unmount — leaked sockets are a common source of "why does this app open 40 connections?"

Presence, Cursors and Ephemeral State

In collaborative tools, not every message deserves the same treatment. Split state into two categories and it becomes far more manageable:

  • Durable state (a task moved, an order shipped) belongs in the database, is published to the channel layer, and is replayable for clients that reconnect.
  • Ephemeral state (who is online, cursor position, "Sam is typing") should never touch the database. Broadcast it directly through the channel layer on a short debounce — a cursor event every 50ms per user is normal traffic, and persisting it would be absurd.

Ephemeral state also fails differently: it is fine to lose it. A dropped cursor message costs nothing, so it needs no acknowledgement, no retry and no ordering guarantee. Keeping the two categories separate in code — different consumers, different payload types — prevents teams from accidentally building replay requirements into a typing indicator.

Three Real Products, Three Answers

ProductRight choiceReasoning
Operations dashboard refreshing every 60sShort polling with cached endpointsWebSocket complexity buys nothing; a poll is one cached query
Live order board for a logistics dispatch teamSSE for updates + REST for actionsOne-way flow, works through proxies, trivial to scale
Collaborative editor or live chatWebSocketsGenuinely bidirectional, latency is the product

If you are unsure, start with polling. It is far easier to replace a poll with a socket later than to explain to a customer why live updates are broken behind their corporate proxy.

Capacity and Cost

Long-lived connections are cheap per socket but not free: estimate roughly 5–15MB of RSS per thousand idle connections on an ASGI worker, plus file descriptors and heartbeat traffic. A single modest container handles a few thousand concurrent business users comfortably; beyond that, add workers and let the channel layer route between them. Measure connections per user before sizing anything — a B2B user with four open tabs is common, and tab-visibility pausing is what keeps the real number close to the theoretical one.

Testing and Fallbacks

Test consumers with ChannelsLiveServerTestCase-style tests, including the negative case: a user without permission must be rejected before joining a group. On the client, test the reconnect logic with a mocked socket.

Finally, always ship a fallback. Corporate networks, VPNs and some proxies block WebSocket upgrades. If the socket fails to connect after two attempts, degrade to polling every 15 seconds and keep the product usable. A live dashboard that breaks entirely on a locked-down network is worse than one that updates a little more slowly.

Related reading: Background tasks with Celery covers the infrastructure that usually sits beside a channel layer; PWAs for business covers push notifications on the client; and the DRF and Nuxt stack covers where this fits in a decoupled app. Build immersive real-time dashboards and live collaboration tools with KEHEM IT.

Need a dashboard that updates without a refresh? Tell us what has to be live.

Have a project in mind?

KEHEM designs and builds thoughtful websites, SaaS products, and business systems.

Talk to KEHEMExplore Services