---
title: "Tutorial: Show who's reading right now with presence"
slug: tutorial-presence
section: guides
order: 67
level: intermediate
description: "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."
---

# 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](/guides/presence/) |
| `presence_key` for grouping presences by resource | [Real-Time Presence](/guides/presence/) |
| `handle_presence_join` / `handle_presence_leave` callbacks | [Real-Time Presence](/guides/presence/) |
| Pairing presence with [Flash Messages](/guides/flash-messages/) | This tutorial |
| Why presence ≠ session — the design rationale | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/) and the
> [real-time comments tutorial](/guides/tutorial-real-time-comments/)
> 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

```python
# 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

```html
<!-- myapp/templates/document.html -->
{% load live_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.
   {% dj_flash %} renders the container itself; do not hand-roll it. #}
{% load djust_flash %}
{% dj_flash %}
```

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 %}` from `djust_flash` | Renders the flash container (`id="dj-flash-container"`, marked `dj-update="ignore"` so patches leave it alone). The flash system listens for `push_event("flash", …)` and renders messages there without a full re-render. See [Flash Messages](/guides/flash-messages/). |

---

## Step 3 — The CSS

```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` and drop the
  `{% cursors %}` overlay into the template. That tag ships in the optional
  components package, so add `djust.components` to `INSTALLED_APPS` and
  `{% load djust_components %}` first. You get Figma-style remote cursors. See [Real-Time Presence § cursors](/guides/presence/).
- **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.
