Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

5 min read

Tutorial: Show who's reading right now with presence

Every shared page benefits from a tiny "who else is here" affordance. On a chat thread it's reassurance. On a document, it's coordination. On a dashboard, it's situational awareness ("Sarah's on call so this graph spike is already known"). The pattern is the same: render an avatar strip with the people currently viewing.

djust ships PresenceMixin for this. By the end of this tutorial you'll have:

  • A small avatar bar at the top of a document page showing every user currently viewing it, with their name on hover.
  • Live join/leave — bar updates within ~200 ms when anyone opens or closes the page.
  • Survives reconnects — the framework's heartbeat keeps a user "present" through a 30-second WebSocket flap; only after that timeout do they fall off.
  • A subtle flash message when a known user joins or leaves ("Alice joined", "Bob left"), implemented with the existing flash-message system.
You'll learnDocumented in
PresenceMixin setup + track_presence()Real-Time Presence
presence_key for grouping presences by resourceReal-Time Presence
handle_presence_join / handle_presence_leave callbacksReal-Time Presence
Pairing presence with Flash MessagesThis tutorial
Why presence ≠ session — the design rationaleThis tutorial

Prerequisites: Quickstart and the real-time comments tutorial are nice context (presence and broadcast are companion patterns for collaborative UIs). A backend with either Redis (recommended) or in-memory channels.


What you're building

┌─────────────────────────────────────────────────────┐
│  Q4 Planning Doc                  ●●●● 4 here       │
│                                   ╳╳╳╳              │
│                                  alice·bob·carla·me │
├─────────────────────────────────────────────────────┤
│  ## Goals                                           │
│  Lorem ipsum...                                     │
│                                                     │
│  Bob just joined. ───────── (flash, fades 4s)       │
└─────────────────────────────────────────────────────┘

Each colored dot is one connected viewer. Hover for the name. The flash message appears briefly when someone joins/leaves.


Step 1 — The view + PresenceMixin wiring

# myapp/views.py
import hashlib

from djust import LiveView
from djust.presence import PresenceMixin

from .models import Document


def _color_for_user(user) -> str:
    """Stable color from username — same user gets the same color
    on every page they appear on. Better than random because the
    visual recognition transfers across docs."""
    h = hashlib.sha1(user.username.encode()).hexdigest()
    return f"#{h[:6]}"


class DocumentView(PresenceMixin, LiveView):
    template_name = "document.html"

    # Presence key isolates each document's presence list. The
    # framework substitutes {doc_id} from kwargs at mount time.
    presence_key = "document:{doc_id}"

    def mount(self, request, *, doc_id: int, **kwargs):
        if not request.user.is_authenticated:
            self.redirect("/login/")
            return
        self.doc_id = doc_id
        self.doc = Document.objects.get(pk=doc_id)
        self.track_presence(meta={
            "name": request.user.username,
            "color": _color_for_user(request.user),
        })

    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx["presences"] = self.list_presences()
        ctx["presence_count"] = self.presence_count()
        return ctx

    def handle_presence_join(self, presence):
        # Skip the flash on the user's own join — only signal others.
        if presence.get("name") == self.request.user.username:
            return
        self.push_event("flash", {
            "message": f"{presence['name']} joined",
            "kind": "info",
        })

    def handle_presence_leave(self, presence):
        if presence.get("name") == self.request.user.username:
            return
        self.push_event("flash", {
            "message": f"{presence['name']} left",
            "kind": "info",
        })

Three things to call out:

  1. presence_key uses {kwarg} substitution — for a doc page, presences for /docs/42/ are isolated from presences for /docs/43/. Two users on different docs don't see each other.
  2. track_presence(meta=...) is what registers you. The meta dict is shipped to every connected viewer of the same key, so anything you put in it (name, color, cursor_x, is_typing, etc.) is broadcast for free.
  3. handle_presence_join/leave fire on the OTHER viewers' sessions when someone joins or leaves — never on the joiner's own session for their own join. Pair with push_event for transient UX (flash message) so you don't have to re-render the whole view.

Step 2 — The avatar bar template

<!-- myapp/templates/document.html -->
{% load djust_tags %}

<header class="doc-header">
  <h1>{{ doc.title }}</h1>

  <div class="presence" aria-label="{{ presence_count }} viewers online">
    <ul class="presence-dots">
      {% for p in presences %}
        <li>
          <span
            class="presence-dot"
            style="background: {{ p.color }}"
            title="{{ p.name }}"
            aria-label="{{ p.name }} is viewing"
          ></span>
        </li>
      {% endfor %}
    </ul>
    <span class="presence-count">{{ presence_count }} here</span>
  </div>
</header>

<article class="doc-body">
  {% djust_markdown doc.body %}
</article>

{# Flash messages — picked up from push_event("flash", ...) above. #}
<div id="flash-area" dj-flash-area></div>

Two patterns the template uses:

PatternEffect
{% for p in presences %} over list_presences()Each render walks the current presence list. The framework re-renders this section automatically when the list changes (someone joins/leaves).
dj-flash-area on a sibling <div>The framework's flash system listens for push_event("flash", …) and renders messages here without a full re-render. See Flash Messages.

Step 3 — The CSS

.doc-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 20px;
  border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.presence {
  display: flex;
  align-items: center;
  gap: 10px;
}
.presence-dots {
  display: flex;
  list-style: none;
  margin: 0;
  padding: 0;
}
.presence-dot {
  display: inline-block;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 2px solid var(--color-bg, #fff);
  margin-left: -6px;     /* overlap stacking */
  cursor: default;
  animation: presence-fade-in 0.25s ease-out;
}
.presence-dots li:first-child .presence-dot {
  margin-left: 0;
}
.presence-count {
  font-size: 12px;
  font-family: var(--font-mono);
  color: var(--color-muted, #6b7280);
}
@keyframes presence-fade-in {
  from { transform: scale(0.5); opacity: 0; }
  to   { transform: scale(1);   opacity: 1; }
}

The negative margin makes the dots overlap into a stack. The fade-in animation is a small touch — when someone joins, their dot pops into existence rather than appearing abruptly.


Step 4 — Try it with two browsers

Open /docs/1/ in two browsers logged in as different users. Within ~200 ms of the second user opening the page, the first user's avatar bar grows from 1 dot to 2 and a flash message "Alice joined" appears. Close the second tab — the dot disappears within ~200 ms and "Alice left" flashes on the first.

Now do something more interesting: open three tabs as alice, bob, carla. The bar shows three dots. Pull alice's network cable (or DevTools → throttling → offline). For 30 seconds her dot remains, because the framework hasn't yet decided she's gone — a brief network blip shouldn't kick a presence. After the heartbeat timeout, her dot fades and "alice left" flashes on the others. Plug back in within the timeout and her presence resumes without re-broadcasting a join.


Why presence ≠ session

A common misread of presence is "show me everyone with an active session." That's not what PresenceMixin does — and it's not what you want. Three reasons:

  1. A user with one session can have N tabs. Sessions count once; presence counts once per active LiveView. If alice has the doc open in two tabs, she's two presences. (You can collapse them client-side by meta.user_id if you want; the default doesn't.)
  2. A session that's been idle for an hour isn't presence. The browser tab might be open behind 50 others, the user gone for coffee. Presence's heartbeat assumes the WebSocket is actively echoing — if it isn't, you fall off in ~30 s.
  3. Presence is per-resource, not per-user. With presence_key = "document:{doc_id}", alice viewing doc 42 and bob viewing doc 43 don't see each other. That's the whole point — "who's looking at this" is the question presence answers.

If you need a global "all online users" list, that's a separate query against the channel layer, not presence.


What just happened, end to end

   Browser A (alice)              Server                  Browser B (bob)
       │                              │                          │
       │  open /docs/1/               │                          │
       │ ──────────────────────────► mount()                     │
       │                              │ track_presence({alice})  │
       │ ◄ initial render             │                          │
       │   (1 dot: alice)             │                          │
       │                              │                          │
       │                              │  ◄────── open /docs/1/ ──│
       │                              │     mount()              │
       │                              │  track_presence({bob})   │
       │                              │  → handle_presence_join  │
       │                              │    fires on alice's view │
       │                              │  → handle_presence_join  │
       │                              │    fires on bob's view   │
       │ ◄ patch: dot + flash ────────│   (skipped on bob's      │
       │   "bob joined"               │    own-join)             │
       │                              │ ─────► initial render ──►│
       │                              │       (2 dots: alice,bob)│
       │                              │                          │
       │  ... 5 minutes later ...     │                          │
       │                              │                          │
       │                              │  ◄ heartbeat (idle 35s) ─│ (network drop)
       │                              │    timeout ► leave bob   │
       │                              │  → handle_presence_leave │
       │ ◄ patch: dot - flash ────────│    fires on alice's view │
       │   "bob left"                 │                          │

Every effect — render, flash, leave — was a single state change or push_event. The framework handles the diff streaming and heartbeat plumbing.


Where to go next

  • Live cursors: swap PresenceMixin for LiveCursorMixin, add dj-cursor-track on the document body, and you get Figma-style remote cursors. See Real-Time Presence § cursors.
  • Typing indicators: put is_typing: true in the meta dict while the user is in the textarea (set on focus, clear on blur or after a debounce). Other viewers see a "alice is typing…" marker.
  • Cross-doc collapse: if the same user has the doc open in multiple tabs, dedupe by meta.user_id in the template: presences|unique:"user_id". The framework keeps each session separate; the client decides what to render.
  • Persisted "last seen": combine presence with a periodic Presence.objects.update_or_create(user=…, doc=…, ts=now) write so you can show "Last viewed by alice 12 minutes ago" even when no one is currently here.

The five-line shape of this tutorial — PresenceMixin + presence_key + track_presence(meta=…) + list_presences() + handle_presence_join/leave — is the entire presence API. Once it clicks, "who's here" becomes a UI primitive you can drop into any LiveView in a few minutes.

Spotted a typo or want to improve this page? Edit on GitHub →