Tutorial: Show who's reading right now with presence
Add a 'who's here' avatar bar to any LiveView in 15 lines. Uses PresenceMixin's join/leave callbacks plus the built-in heartbeat to keep it accurate even through reconnects, idle tabs, and crashed clients.
On this page
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.
- Accurate through crashes — the client's 30-second ping doubles as the presence heartbeat, and a presence with no heartbeat for 60 seconds is dropped, so a crashed tab doesn't linger.
- A subtle flash message when another 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 |
_on_presence_change for reacting to other users | 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.decorators import event_handler
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"
login_required = True
# Presence key isolates each document's presence list. The
# {doc_id} placeholder is filled from the view's own attributes,
# so set self.doc_id before calling track_presence().
presence_key = "document:{doc_id}"
# A join or leave wakes only the sessions on the same document,
# not every open DocumentView.
presence_broadcast_scoped = True
def mount(self, request, *, doc_id: int, **kwargs):
self.doc_id = doc_id
# Only a permitted user joins the presence list. djust runs
# get_object() / has_object_permission() itself right after
# mount() and refuses the page, but mount() runs first, and a
# presence registered here would show a refused user's name to
# the document's real viewers.
doc = self.get_object()
if doc is not None and self.has_object_permission(request, doc):
self.track_presence(meta={
"name": request.user.username,
"color": _color_for_user(request.user),
})
self._seen = self._viewers()
def get_object(self):
# None (not found) and a denial look the same to the client.
return Document.objects.filter(pk=self.doc_id).first()
def has_object_permission(self, request, obj):
# Your access rule. Here: the document's members.
return obj.members.filter(pk=request.user.pk).exists()
def get_presence_user_id(self):
# One presence per TAB, not per user. With the default (one per
# user), closing one of alice's two tabs would remove her from
# the list while the other tab is still open. The id is hashed
# because every viewer receives it: list_presences() returns it,
# and a legacy view also copies it into meta["user_id"].
per_tab = f"{self.request.user.pk}:{self._websocket_session_id}"
return hashlib.sha256(per_tab.encode()).hexdigest()[:16]
def _viewers(self):
# Each presence record is {"id", "joined_at", "meta"}; the dict
# passed to track_presence() is under "meta". Several tabs of one
# user are several records, so collapse them by name.
viewers = {}
for p in self.list_presences():
viewers.setdefault(p["meta"].get("name", ""), p["meta"])
return viewers
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["doc"] = self._object
ctx["viewers"] = list(self._viewers().values())
return ctx
@event_handler
def _on_presence_change(self, **kwargs):
# Runs on every OTHER viewer's session when someone joins or
# leaves. Keep the built-in online_count refresh.
super()._on_presence_change(**kwargs)
now = self._viewers()
for name in now.keys() - self._seen.keys():
self.put_flash("info", f"{name} joined")
for name in self._seen.keys() - now.keys():
self.put_flash("info", f"{name} left")
self._seen = now
This assumes Document has a members many-to-many to your user
model. Use whatever rule decides who may read a document.
Five things to call out:
- Access comes first.
get_object()andhas_object_permission()are djust's object-permission hooks, the same ones the authentication tutorial uses. djust runs them aftermount()on the page load (a refused user gets HTTP 403), on the WebSocket mount (refused with close code 4403) and before every event.mount()checks them too before it callstrack_presence(), because it runs before djust's own check.get_context_data()reads the document fromself._object, the copy djust cached when the check passed. presence_keyuses{attribute}substitution from the view's attributes. 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.presence_broadcast_scoped = Truemakes a join wake only the sessions on the same document.track_presence(meta=...)is what registers you. Themetadict is returned to every connected viewer of the same key, so put in it only what every viewer may see (name,color,is_typing, etc.). Presence registers only over the WebSocket; the first HTTP render skips it.get_presence_user_id()decides what one presence is. The default is one per logged-in user, which collapses tabs and makes the first closed tab remove the user. Keying by tab keeps the user listed until their last tab closes, and_viewers()collapses their tabs back into one dot._on_presence_changeis the hook that fires on the OTHER viewers' sessions when someone joins or leaves. It carries no payload, so the view diffs the viewer list against the one it saw last time. (handle_presence_join/handle_presence_leaveexist too, but they run only on the session that is itself joining or leaving, so they can't tell others.) Override it with@event_handlerand callsuper()to keep the built-inonline_countrefresh.
Step 2 — The avatar bar template
<!-- myapp/templates/document.html -->
<div dj-root>
<header class="doc-header">
<h1>{{ doc.title }}</h1>
<div class="presence" aria-label="{{ viewers|length }} viewers online">
<ul class="presence-dots">
{% for v in viewers %}
<li dj-key="{{ v.name }}">
<span
class="presence-dot"
style="background: {{ v.color }}"
title="{{ v.name }}"
aria-label="{{ v.name }} is viewing"
></span>
</li>
{% endfor %}
</ul>
<span class="presence-count">{{ viewers|length }} here</span>
</div>
</header>
<article class="doc-body">
{% djust_markdown doc.body %}
</article>
{# Flash messages from put_flash(); dj_flash renders the container itself. #}
{% load djust_flash %}
{% dj_flash %}
</div>
{% djust_markdown %} is a djust template tag, so this template
needs the DjustTemplateBackend listed first in TEMPLATES (the
layout djust new generates; see
Template Requirements). Under Django's
own template backend it fails with Invalid block tag 'djust_markdown'.
Three patterns the template uses:
| Pattern | Effect |
|---|---|
dj-root on the outer <div> | Marks the reactive region. djust stamps dj-view onto it when it renders the page, and the client connects to that element. Without it the page renders but never connects, and no one ever appears in the bar. |
{% for v in viewers %}, {{ viewers|length }} | Each render walks the viewer list (one entry per person, however many tabs they have open). _on_presence_change re-renders the other viewers when someone joins or leaves. |
{% dj_flash %} from djust_flash | Renders the flash container (id="dj-flash-container", marked dj-update="ignore" so patches leave it alone). Messages queued with put_flash() appear there 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
"bob joined" appears. Close the second tab — the dot disappears
and "bob left" flashes on the first.
Now try a harder case: open three browsers as alice, bob and
carla, then kill bob's browser process instead of closing the
tab. If the server never sees the socket close, bob's record stays
until his heartbeat is 60 seconds old; after that he is dropped
from the list the next time it is read. When bob comes back, his
page mounts again and he rejoins.
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:
- What one presence is, is your choice. By default a
logged-in user's presence id is their user id, so alice with the
doc open in two tabs is one presence, and closing either tab
removes it. This tutorial keys presence by tab instead
(
get_presence_user_id()) and collapses tabs by name when it renders. Anonymous visitors collapse per browser session; setpresence_unique_per_connection = Trueto count each anonymous tab separately. - 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 — but a tab whose WebSocket is gone stops heartbeating, and its presence is dropped after 60 seconds.
- 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, not presence.
What just happened, end to end
Browser A (alice) Server Browser B (bob)
│ │ │
│ open /docs/1/ │ │
│ ──────────────────────────► mount() │
│ │ track_presence({alice}) │
│ ◄ render (1 dot: alice) │ │
│ │ │
│ │ ◄────── open /docs/1/ ──│
│ │ mount() │
│ │ track_presence({bob}) │
│ │ → push _on_presence_ │
│ │ change to alice │
│ ◄ patch: dot + flash ────────│ ─────► render ──────────►│
│ "bob joined" │ (2 dots) │
│ │ │
│ ... 5 minutes later ... │ │
│ │ │
│ │ ◄──── socket closes ────│ (tab closed)
│ │ untrack_presence(bob) │
│ │ → push _on_presence_ │
│ ◄ patch: dot - flash ────────│ change to alice │
│ "bob left" │ │
Every effect — render, flash, leave — was a state change or a
put_flash(). The framework handles the diff streaming and
heartbeat plumbing.
Where to go next
- Live cursors: swap
PresenceMixinforLiveCursorMixinand drop the{% cursors %}overlay into the template. That tag ships in the optional components package, so adddjust.componentstoINSTALLED_APPSand{% load djust_components %}first. 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. - 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 shape of this tutorial — an access check, then PresenceMixin +
presence_key + track_presence(meta=…) + list_presences() +
_on_presence_change — 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.