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 learn | Documented in |
|---|---|
PresenceMixin setup + track_presence() | Real-Time Presence |
presence_key for grouping presences by resource | Real-Time Presence |
handle_presence_join / handle_presence_leave callbacks | Real-Time Presence |
| Pairing presence with Flash Messages | This tutorial |
| Why presence ≠ session — the design rationale | This 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:
presence_keyuses{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.track_presence(meta=...)is what registers you. Themetadict 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.handle_presence_join/leavefire on the OTHER viewers' sessions when someone joins or leaves — never on the joiner's own session for their own join. Pair withpush_eventfor 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:
| Pattern | Effect |
|---|---|
{% 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:
- 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_idif you want; the default doesn't.) - 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.
- 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
PresenceMixinforLiveCursorMixin, adddj-cursor-trackon the document body, and you get Figma-style remote cursors. See Real-Time Presence § cursors. - Typing indicators: put
is_typing: truein 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_idin 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.