djust docs
Browse documentation
intermediate

Real-Time Presence Tracking

Track online users and live cursors with PresenceMixin and LiveCursorMixin

On this page

djust provides a presence system for tracking which users are currently viewing a page, with support for live cursors and collaborative features. Inspired by Phoenix LiveView's Presence.

What You Get

  • PresenceMixin -- Track user presence in any LiveView with join/leave callbacks
  • CursorTracker -- Track and broadcast live cursor positions
  • LiveCursorMixin -- Combined presence + cursor tracking in a single mixin
  • Stale-presence cleanup -- Presences with no heartbeat for 60 seconds are pruned. The client's connection ping (every 30 seconds) is the heartbeat

Quick Start

Minimal (v1.0.0rc12+): zero-config online count

For just an online-user counter, you don't need any custom context or handlers — PresenceMixin auto-maintains self.online_count and auto-broadcasts join/leave to all sessions of the same view:

from djust import LiveView
from djust.presence import PresenceMixin

class DemoView(PresenceMixin, LiveView):
    template_name = 'demo.html'
    presence_key = "demo"
    # For anonymous-tab demos where two browser tabs of one user should
    # count as two presences (not collapse to one), opt in below:
    # presence_unique_per_connection = True

    def mount(self, request, **kwargs):
        self.track_presence()
<span class="presence-chip">{{ online_count }} online</span>

That's the entire surface. online_count is set as an instance attribute (so djust's diff dirty-tracking emits patches when it changes) and the broadcast fans out to other sessions automatically. Open the page in two browser tabs — both chips show 2 online. Close one — the other drops to 1 online when the closed tab's WebSocket disconnects.

Note: HTTP vs WebSocket mounts. track_presence() is a no-op during the HTTP-prerender phase of the page load — presence only registers when the WebSocket consumer mounts the view. This prevents orphan presence records on the throwaway HTTP view instance. No caller action is required; it's transparent.

Full: presence with metadata and per-user avatars

from djust import LiveView
from djust.presence import PresenceMixin

class DocumentView(PresenceMixin, LiveView):
    template_name = 'document.html'
    presence_key = "document:{doc_id}"

    def mount(self, request, **kwargs):
        self.doc_id = kwargs.get("doc_id")
        self.track_presence(meta={
            "name": request.user.username,
            "color": "#6c63ff",
        })

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["presences"] = self.list_presences()
        # `online_count` is already on `self` (v1.0.0rc12+); the
        # imperative `presence_count()` method is still available too.
        return ctx

Display Presence in Templates

<div class="presence-bar">
    {{ online_count }} users online
    {% for p in presences %}
        <span class="avatar" style="background: {{ p.meta.color }}">
            {{ p.meta.name.0 }}
        </span>
    {% endfor %}
</div>

Anonymous tabs (presence_unique_per_connection)

By default, two browser tabs of the same anonymous user share a Django session and therefore one presence id (anon_<session_key>) — the live count stays at "1 online" no matter how many tabs you open. This is the right semantics for an authenticated user collaborating with themselves (same person, one identity), but wrong for a demo or a counter where each tab should be counted independently.

Opt in to per-connection uniqueness for the anonymous path:

class DemoView(PresenceMixin, LiveView):
    presence_key = "demo"
    presence_unique_per_connection = True   # anonymous tabs count distinctly

Authenticated users always use request.user.id regardless of the flag — logged-in tabs still collapse to one identity (intentional).

Each presence record is {"id": <user id>, "joined_at": <timestamp>, "meta": <the dict you passed to track_presence>}, so your metadata lives under meta.

3. Handle Join/Leave Events

These callbacks run on the view that is itself joining or leaving, not on the other users' sessions. Use them for per-session work such as a welcome message:

class DocumentView(PresenceMixin, LiveView):
    def handle_presence_join(self, presence):
        self.push_event("flash", {
            "message": f"Welcome, {presence['meta']['name']}"
        })

    def handle_presence_leave(self, presence):
        self.push_event("flash", {
            "message": f"Goodbye, {presence['meta']['name']}"
        })

To react when other users join or leave (for example to flash "Alice joined" to everyone else), override _on_presence_change, which fires on peer sessions, call super()._on_presence_change(**kwargs), and diff list_presences() against the list you saw last time.

Multi-room views: who a join wakes

Each join or leave pushes _on_presence_change to peer sessions. In a view that sets push_scope (one room per session), that push reaches only the sessions that share this session's presence key, so a join in one room does not wake every other room. Every WebSocket session of such a view is in the group of its own presence key, including sessions that show online_count without calling track_presence(). For a session that tracks, the key is the one track_presence() used; for one that does not, it is worked out again whenever its push_scope changes.

Sessions that share a presence key must agree on scoping. If only some sessions of a view set push_scope (a lobby without one, rooms with one), set presence_broadcast_scoped = True on the class so they all join.

A view without push_scope keeps the view-wide broadcast. Set presence_broadcast_scoped to choose explicitly:

class RoomView(PresenceMixin, LiveView):
    presence_key = "room:{room}"
    presence_broadcast_scoped = True   # scoped even without push_scope
    # presence_broadcast_scoped = False  # every session of the view, every room

Use False only when an _on_presence_change override must hear about joins and leaves under other presence keys.

PresenceMixin API

Class Attributes

AttributeTypeDefaultDescription
presence_keystr or NoneNoneGroup identifier. Supports format variables from view attributes (e.g., "doc:{doc_id}").
presence_broadcast_scoped (v1.3+)bool or NoneNoneWho a join or leave wakes. None: the sessions sharing the presence key when the view sets push_scope, otherwise every session of the view. True: the sessions sharing the presence key. False: every session of the view.
presence_unique_per_connection (v1.0.0rc12+)boolFalseWhen True, anonymous users get a per-WebSocket-connection unique id (anon_conn_<ws_session_id>) instead of a per-session id. Authenticated users always use user.id regardless. Use for anonymous-tab demos.

Instance Attributes (auto-maintained)

AttributeTypeDescription
online_count (v1.0.0rc12+)intNumber of active presences in the group. Auto-set by track_presence, untrack_presence, _restore_presence, and _on_presence_change. Use directly in templates: {{ online_count }}.

Methods

MethodDescription
track_presence(meta=None)Start tracking this user. Meta dict can include name, color, avatar, etc. No-op during HTTP-prerender (registers only under WebSocket).
untrack_presence()Stop tracking. Called automatically on disconnect.
list_presences()Returns all active presences in the group as a list of dicts.
presence_count()Returns count of active users (imperative method; for template binding prefer {{ online_count }}).
get_presence_key()Returns formatted presence key. Override for dynamic keys.
get_presence_user_id()Returns unique user ID. Defaults to request.user.id for authenticated users, anon_conn_<ws_session_id> if presence_unique_per_connection=True, else anon_<session_key>.
broadcast_to_presence(event, payload)Broadcast a custom event to all users in the group.

Callbacks

CallbackWhen Called
handle_presence_join(presence)This view's own track_presence() joins the group (runs on the joining session only)
handle_presence_leave(presence)This view's own untrack_presence() leaves the group (runs on the leaving session only)
_on_presence_change(**kwargs) (v1.0.0rc12+)Auto-fires on other sessions when this view's track/untrack runs: every session of the view, or only those sharing the presence key (see presence_broadcast_scoped). Default body refreshes online_count. Override to do additional work; call super()._on_presence_change(**kwargs) to preserve the count refresh.

CursorTracker

Manages live cursor positions using Django's cache framework.

from djust.presence import CursorTracker

# Update a cursor position
CursorTracker.update_cursor("doc:123", user_id, x=450, y=200, meta={"color": "#e74c3c"})

# Get all cursors for a group
cursors = CursorTracker.get_cursors("doc:123")
# Returns: {user_id: {x, y, timestamp, meta}}

# Remove a cursor
CursorTracker.remove_cursor("doc:123", user_id)

Cursors time out after 10 seconds to avoid showing stale positions.

LiveCursorMixin

Combines PresenceMixin with cursor tracking for collaborative editing and whiteboard features.

from djust import LiveView
from djust.presence import LiveCursorMixin
from djust.decorators import event_handler

class WhiteboardView(LiveCursorMixin, LiveView):
    template_name = 'whiteboard.html'
    presence_key = "whiteboard:{board_id}"

    def mount(self, request, **kwargs):
        self.board_id = kwargs.get("board_id")
        self.track_presence(meta={
            "name": request.user.username,
            "color": self.assign_color(),
        })

    @event_handler()
    def cursor_move(self, x=0, y=0, **kwargs):
        self.handle_cursor_move(int(x), int(y))

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["cursors"] = self.get_cursors()
        return ctx

    def assign_color(self):
        colors = ["#e74c3c", "#3498db", "#2ecc71", "#f39c12", "#9b59b6"]
        return colors[self.presence_count() % len(colors)]

Example: Chat Room with Online Users

from djust import LiveView
from djust.presence import PresenceMixin

class ChatView(PresenceMixin, LiveView):
    template_name = 'chat.html'
    presence_key = "chat:{room_id}"

    def mount(self, request, **kwargs):
        self.room_id = kwargs.get("room_id")
        self.track_presence(meta={
            "name": request.user.username,
            "avatar": request.user.profile.avatar_url,
        })

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["online_users"] = self.list_presences()
        ctx["online_count"] = self.presence_count()
        return ctx
<aside class="sidebar">
    <h3>Online ({{ online_count }})</h3>
    <ul class="user-list">
        {% for user in online_users %}
        <li>
            <img src="{{ user.meta.avatar }}" alt="{{ user.meta.name }}">
            <span>{{ user.meta.name }}</span>
        </li>
        {% endfor %}
    </ul>
</aside>

Best Practices

  • Heartbeat: A presence is stale, and pruned by the next list_presences() / count refresh, if no heartbeat arrives within 60 seconds (PRESENCE_TIMEOUT). The client pings its WebSocket every 30 seconds and the server refreshes the view's tracked presence on each ping, so a user stays listed while the page is open. (Before 1.2.1 nothing refreshed it and users dropped out after about a minute; #2968.) Browsers throttle timers in background tabs, so a tab hidden for several minutes can ping less often than every 30 seconds.
  • Cursor timeout: Positions expire after 10 seconds. Use CursorTracker for high-frequency cursor updates.
  • Presence keys: Use descriptive, hierarchical keys like "document:{doc_id}" or "room:{room_id}". Format variables resolve from view attributes.
  • Cleanup: Presences are removed automatically on WebSocket disconnect. Stale presences (missed heartbeats) are pruned when the group is next listed or counted.
  • Backend selection: Use the memory backend for development, Redis for multi-server production deployments. Configure via DJUST_CONFIG['PRESENCE_BACKEND'] ('memory' or 'redis') and PRESENCE_REDIS_URL.