Presence and live cursors
Showing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
The problem: editing next to people you cannot see
Picture two people, Asha and Ben, working on the same planning doc. The editor merges their typing correctly, so no letter is lost, but neither can see the other. Asha starts rewriting the pricing paragraph while Ben, at the same moment, deletes it because he thinks it is out of date.
Nothing broke in a technical sense. What failed is the human side: they had no idea they were in the same spot. A tiny colored cursor named "Ben" next to the pricing paragraph would have stopped Asha in one second.
That is presence: the live, shared answer to "who else is here, and what are they doing right now?" It covers the avatar stack in the toolbar, colored cursors on a canvas, text another person has selected, and hints like "Ben is typing".
What counts as presence
- Who is online: the people connected to this document, with name, avatar, and color.
- Where they are: the pointer position on a canvas, or the text caret and selection in a document.
- What they are doing: typing, dragging a shape, idle, or away.
- What they are looking at: the page, slide, or viewport they have open, so you can follow them.
All of these only matter right now. Where Ben's cursor was ten seconds ago is useless: nobody wants to replay it, store it, or merge it. This one fact shapes the whole design and makes presence very different from the document itself.
Why it is harder than it looks
A first version takes an afternoon: send mousemove positions over a WebSocket and draw a dot for each one. Then real use arrives. Ten people move their mouse at once and the server sends thousands of messages per second, while cursors jump across the screen in steps instead of gliding.
Someone closes their laptop lid and their cursor stays frozen on the page for minutes, a "ghost". A room with 300 viewers turns into a cloud of dots nobody can read.
So the real problem has four parts, taken in order: keep the update rate cheap (throttling and batching), make movement look smooth despite few updates (interpolation), know quickly and correctly when someone has gone (heartbeats and timeouts), and keep it working when rooms get big (sharding and sampling). Along the way we cover idle detection and screen reader support.
The mental model: a roster on a whiteboard, not minutes in a book
Think of a meeting room. Someone keeps the minutes: every decision is written down, in order, and kept forever. That is the document. Next to the door is a whiteboard roster: each person writes their name and seat when they walk in and wipes it when they leave, and nobody keeps old versions.
Presence is the whiteboard. Each connected tab owns one small entry, keeps it current, and the entry is wiped when the tab goes. If an entry is wrong for a moment, the next update fixes it, because everyone only wants the latest copy.
Ephemeral state versus persisted state
Ephemeral state lives only in memory, only while people are connected. Persisted state is saved to storage and survives everyone leaving. This split is the first design decision, and it changes almost every rule.
| Question | Document state (persisted) | Presence state (ephemeral) |
|---|---|---|
| Does every update matter? | Yes: losing one edit loses work | No: only the latest value matters |
| Order | Must be applied in a defined order, or merged by OT or a CRDT | Latest wins per client; old updates can be dropped |
| Storage | Database, snapshots, history | Server memory only, gone when the room empties |
| Lost message | Must be resent until acknowledged | Ignore it: the next update replaces it |
| Update rate | A few per second per person while typing | 10 to 30 per second per person while moving the mouse |
| Undo, history, offline | Yes | No: an offline person has no presence |
So presence can run on simpler machinery: no database write per update, no acknowledgment, no merge algorithm. A dictionary in server memory, keyed by connection id, is enough for one room.
The shape of a presence entry
Keep each entry small and flat, because it is copied to every other person in the room many times per second.
type Presence = {
connectionId: string; // one per tab, assigned by the server
userId: string; // the same person can have several tabs
name: string;
color: string; // stable per user, chosen from a palette
cursor: { x: number; y: number } | null; // document coordinates
selection: { anchor: string; head: string } | null; // ids, not offsets
status: "active" | "idle" | "away";
viewing?: string; // page, slide, or frame id
};
// Kept by the server next to each entry, never sent by the client:
type ServerMeta = { lastSeen: number; clock: number };
Two fields need care. The cursor is in document coordinates, not screen pixels: Asha is on a large monitor at 100% zoom and Ben on a laptop at 50% zoom, scrolled down, so a raw clientX of 400 means a different spot for each. Each client converts its pointer to document space before sending and back to its own screen before drawing.
The selection uses ids that survive edits. If Ben selects characters 120 to 140 and Asha then types 10 characters above, offset 120 points at the wrong word. CRDT-based editors give you stable positions for this (Yjs calls them relative positions), so the selection follows its text.
Think first
A product manager asks you to "save cursor positions so people can see where others were working yesterday". Should that go into the presence channel?
No. That is a different feature with different rules: it needs storage, it needs to survive everyone leaving, and it updates rarely (say, once when a person leaves a section). Model it as persisted document data, like a "last edited by" marker written when someone stops editing.
Keep the live presence channel ephemeral. Mixing the two means either writing to the database 20 times per second per person, or losing the history you promised.
How it works, step by step
Let us follow Ben opening a design file where Asha is already working. One WebSocket per tab carries both document edits and presence messages, each with a type field so the server can route it. The room is the document: everyone with the same file open is in it.
joined, then only from short p messages until left.The five steps of a presence session
- Join. Ben's tab opens the socket and sends
joinwith the document id. The server checks he may read this document, assigns aconnectionIdsuch asc7, and adds him to the room. - Snapshot. The server replies with the full roster: every current entry, Asha's included. Ben's client draws her avatar and cursor right away, without waiting for her to move.
- Announce. Ben sends his own first entry (name, color, cursor
null, statusactive). The server stores it and sends it to everyone else as ajoinedevent. - Update. As Ben moves, his client sends only the fields that changed, such as
{ cursor: { x: 812, y: 340 } }. Each field is sent whole, never as a delta like "5 px to the right", so the newest value always replaces the old one. The server merges it into his entry and forwards it to the room. - Leave. When Ben closes the tab, the socket closes and the server removes
c7and tells the roomleft: c7. If the socket dies without a clean close, a timeout does the same job later (chapter 6).
A newcomer needs the whole picture once; after that, small changes are enough. This "state then diffs" pattern is used by most sync protocols, and it means a dropped update is never fatal: on reconnect, the client gets a fresh snapshot and sends its whole entry again.
What the server keeps
Per room, the server keeps a map from connection id to entry, plus the time it last heard from each connection. That is the entire state: 20 people with 200 byte entries take about 4 KB.
Messages on the wire, with real sizes
// server -> Ben, once, after join (about 300 bytes for 2 people)
{ "t": "snapshot", "self": "c7", "peers": {
"c3": { "userId": "u_asha", "name": "Asha", "color": "#7c3aed",
"cursor": { "x": 420, "y": 96 }, "status": "active" } } }
// Ben -> server, while moving (about 30 bytes)
{ "t": "p", "c": [812, 340] }
// server -> everyone else (about 40 bytes)
{ "t": "p", "id": "c7", "c": [812, 340] }
// server -> everyone, when Ben goes
{ "t": "left", "id": "c7" }
The cursor message is short on purpose. In a busy room, cursor updates are most of all messages sent, so every byte is multiplied by people, by rate, and by fan-out. Rounding to whole document units and sending [812,340] instead of {"x":812.3456,"y":340.1234} roughly halves the message. Binary frames save more, but JSON with short keys is a fine start.
Out-of-order updates and the per-entry clock
Over a single WebSocket, messages from one sender arrive in order. But presence can also reach you along two paths, for example after a reconnect when a snapshot and a live update cross.
To make "latest wins" safe, each entry carries a small counter, a clock, that its owner increases on every change. A receiver ignores any update whose clock is not higher than the one it already has. Yjs's awareness protocol works this way.
Check your understanding · Exercise 1
Ben's socket drops for 8 seconds and reconnects. During the gap, Asha moved her cursor and Chen joined. What should Ben's client do on reconnect? Select all that apply.
Throttled cursor broadcasts: the rate math
The browser fires pointermove very often. Chromium aligns it with screen frames, so it arrives up to about 60 times per second on a common display and 120 on a fast one. Sending each event to the server would be wasteful: as the next chapter shows, a smoothed cursor at 20 updates per second looks almost as good as one at 60.
So the client throttles: at most one update per interval, always the latest position, and nothing when the pointer is still. The Debounce and throttle lesson covers the general technique; here are the presence-specific rules.
Four rules for a presence throttle
- Send the latest value, not the first. Keep overwriting a
latestvariable and send it when the timer fires (a trailing send), so others see where the cursor is, not where it was. - Send the final position. When the pointer stops, the last move must still go out, or the remote cursor stops a few pixels short.
- Send nothing when nothing changed. Re-sending the same point every 50 ms is pure waste. Only schedule a send when a new event arrives.
- Send
nullwhen the pointer leaves. Onpointerleaveof the canvas, sendcursor: nullso others hide your cursor instead of leaving it parked on the edge.
const INTERVAL = 50; // at most 20 updates per second
let latest: [number, number] | null = null;
let timer: number | undefined;
function flush() {
timer = undefined;
if (latest) socket.send(JSON.stringify({ t: "p", c: latest }));
}
canvas.addEventListener("pointermove", (event) => {
const p = toDocumentPoint(event); // screen -> document space
latest = [Math.round(p.x), Math.round(p.y)];
timer ??= window.setTimeout(flush, INTERVAL);
});
canvas.addEventListener("pointerleave", () => {
latest = null;
window.clearTimeout(timer);
timer = undefined;
socket.send(JSON.stringify({ t: "p", c: null }));
});
Fan-out: why rooms get expensive fast
Each update from one person must reach everyone else in the room. That is fan-out. With N people each sending r updates per second, the server receives N x r messages and sends N x (N - 1) x r. The outgoing count grows with the square of the room size.
| People in room | In (msgs/s) at 20 Hz | Out (msgs/s) at 20 Hz | Out bandwidth at 40 bytes each |
|---|---|---|---|
| 2 | 40 | 40 | 1.6 KB/s |
| 10 | 200 | 1,800 | 72 KB/s |
| 25 | 500 | 12,000 | 480 KB/s |
| 50 | 1,000 | 49,000 | 1.96 MB/s |
| 200 | 4,000 | 796,000 | 31.8 MB/s |
Each receiving client also pays. In a 50 person room, every tab gets 980 messages per second: about 16 per frame, each parsed as JSON and pushed into state. That alone can make a slow laptop drop frames.
Batching on the server
The fix for the message count is batching: the server collects all changes in a room for one tick (say, 50 ms), then sends each client a single message holding every cursor that moved. Updates from the same person inside one tick collapse into the newest one.
With batching, each client receives at most one message per tick no matter how many people moved: 20 messages per second instead of 980. The bytes still grow with room size, since each batch holds up to N - 1 cursors, but the per-message overhead (frame headers, JSON parse calls, React state updates) drops sharply. Try both in the calculator.
Outgoing bandwidth for this room: about 72.0 KB/s. Each tab handles about 3 messages per frame at 60 frames per second.
Decision Where should the update rate be limited?
What we pick: Always throttle on the client: it is free and stops most waste at the source. Add server batching once rooms regularly pass about 10 people, and an adaptive rate only for large rooms. Also enforce a per-connection limit on the server, so one client cannot flood the room.
Check your understanding · Exercise 2
A whiteboard room has 20 people, each sending cursor updates at 25 Hz. Without batching, how many messages per second does the server send out for this room, worst case? Pick one.
Smooth movement: interpolation and dead reckoning
Throttling creates a new problem on the receiving side. At 20 updates per second, a new position arrives every 50 ms. If you move the remote cursor straight to each one, it teleports in 50 ms steps, which the eye reads as stutter. Add network jitter (updates arriving 30 ms, then 80 ms, then 40 ms apart) and it looks broken, even though the data is fine.
The fix is to separate receiving positions from drawing them. Received positions become targets, and a loop that runs on every animation frame moves each remote cursor toward its target in small steps. There are three ways to do this, trading smoothness against delay and accuracy.
Way 1: ease toward the latest target (lerp)
Lerp is short for linear interpolation: a point part of the way from A to B. On each frame you move the drawn cursor a fixed share of the remaining distance to the target. Far from the target it moves fast; close to it, it slows down. The result is a soft, slightly trailing glide that never overshoots.
type Point = { x: number; y: number };
type Remote = { drawn: Point; target: Point };
// TAU is the smoothing time: after TAU ms the cursor has
// covered about 63% of the gap. 50 to 100 ms is a good start.
const TAU = 80;
function step(remotes: Map<string, Remote>, dtMs: number) {
// Frame-rate independent: the same feel at 60 and 120 Hz.
const k = 1 - Math.exp(-dtMs / TAU);
for (const r of remotes.values()) {
r.drawn.x += (r.target.x - r.drawn.x) * k;
r.drawn.y += (r.target.y - r.drawn.y) * k;
}
}
let last = performance.now();
function frame(now: number) {
step(remotes, Math.min(now - last, 100));
last = now;
drawCursors(remotes); // set transform on each cursor element
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
A common mistake is the fixed factor, like drawn += (target - drawn) * 0.3 on every frame. On a 120 Hz display that runs twice as often, so cursors close the gap twice as fast as on a 60 Hz one. Deriving the factor from the elapsed time, as above, fixes it. The Math.min(..., 100) cap limits the step after one long frame, such as the first one when a background tab is shown again.
Jump to each update: about 71 px behind the real pointer on average, still on 84% of frames, biggest jump 58 px in one frame. The cursor holds still, then leaps: that reads as stutter.
Way 2: play back slightly in the past
Lerp cuts corners: if Asha draws a sharp zigzag, her remote cursor rounds it off. When the exact path matters, use buffered interpolation, a technique from multiplayer games. Keep the last few received positions, each with its timestamp.
Then draw the cursor not at "now" but at "now minus a delay" of about one and a half update intervals, for example 75 ms at 20 Hz. At that moment in the past you almost always have a position before and after, so you can draw exactly between them.
The cost is a fixed delay: the cursor is always about 75 ms behind, which nobody notices on a cursor. If a packet is late and the buffer runs dry, hold the last position (or switch to lerp) until the next one arrives.
Way 3: predict ahead (dead reckoning)
Dead reckoning is the sailor's method: from the last known position, speed, and heading, guess where the ship is now. For cursors, take the velocity from the last two updates and keep moving the cursor along it between updates. In steady motion this removes the delay.
The catch: pointers stop and turn suddenly. When Asha stops, a predicting cursor overshoots her real position, then snaps back when the next update arrives, which looks worse than a small lag. So dead reckoning is usually limited to at most one interval ahead and blended with lerp. It suits objects with momentum, like a flung shape, better than bare pointers.
Decision How should remote cursors be smoothed?
What we pick: Start with lerp: it has the best effort-to-quality ratio and makes 10 to 20 Hz feel smooth. Move to buffered interpolation or a spline when your product is about watching others draw, like a whiteboard or follow mode. Use dead reckoning only for objects that keep moving on their own.
Check your understanding · Exercise 3
Remote cursors look jumpy on a 120 Hz monitor but fine on 60 Hz. The smoother runs drawn += (target - drawn) * 0.25 once per animation frame. What is going on? Select all that apply.
Heartbeats, timeouts, and idle or away
Joining is easy to detect: a socket opens. Leaving is not. When someone closes a tab normally, the socket closes and the server removes them at once, but very often no close ever arrives.
A laptop lid shuts and the machine sleeps. A phone goes into a lift and loses signal. A proxy drops an idle connection without telling either side. The server's socket can look open for minutes while nobody is on the other end.
Without a fix, that person's cursor freezes on everyone's screen: a ghost. Ghosts are the most visible presence bug: if the avatar stack says five people are here and two of them left an hour ago, users stop believing any of it.
Heartbeat plus timeout
The standard fix has two halves. Each client sends a heartbeat, a tiny "still here" message, on a fixed interval even when nothing changed (any presence update counts too). The server records lastSeen for each connection using its own clock. A sweep runs every few seconds, removes any entry whose lastSeen is older than the timeout, and tells the room.
The timeout must be a few heartbeats long, so one late or lost heartbeat does not remove a person who is still there. Yjs's awareness protocol is a good reference: clients renew their state every 15 seconds, and peers drop a state not renewed for 30 seconds. Page JavaScript cannot send WebSocket ping frames, so the heartbeat is an ordinary application message.
Ben's cursor stays on screen as a ghost for 20 s after his laptop sleeps, until the timeout removes it at 60 s. Heartbeat traffic: 67 messages per second for every 1,000 people online.
| Setting | Ghosts last up to | Survives a network blip of | Heartbeat traffic per 1,000 users |
|---|---|---|---|
| Every 5 s, timeout 15 s | 15 s | about 10 s | 200 msgs/s |
| Every 15 s, timeout 30 s | 30 s | about 15 s | 67 msgs/s |
| Every 30 s, timeout 90 s | 90 s | about 60 s | 33 msgs/s |
Active, idle, away, gone
Online or offline is too coarse. A person who left the tab open and went to lunch an hour ago is still connected, yet showing their cursor in the middle of the canvas misleads everyone. Most products use a few states, each with its own look.
- Active: tab visible with recent input. Full avatar, cursor shown.
- Idle: tab visible but no pointer or key input for a while (2 to 5 minutes is a reasonable choice). Avatar dimmed, cursor hidden.
- Away: tab hidden, detected with the Page Visibility API. Avatar dimmed with a badge, cursor hidden at once.
- Gone: socket closed or timeout passed. Entry removed, with a short fade.
Detecting away with Page Visibility
The Page Visibility API tells a page whether it is shown. document.visibilityState is visible or hidden, and the visibilitychange event fires when that flips: switching tabs, minimizing the window, or locking a phone. Every modern browser supports it, so there is no need to guess from mouse silence.
const IDLE_AFTER = 3 * 60_000; // 3 minutes without input
let idleTimer: number | undefined;
function setStatus(status: "active" | "idle" | "away") {
presence.update({ status, cursor: status === "active" ? presence.cursor : null });
}
function onInput() {
if (document.visibilityState !== "visible") return;
if (presence.status !== "active") setStatus("active");
window.clearTimeout(idleTimer);
idleTimer = window.setTimeout(() => setStatus("idle"), IDLE_AFTER);
}
for (const type of ["pointermove", "keydown", "pointerdown", "wheel"]) {
window.addEventListener(type, onInput, { passive: true });
}
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") setStatus("away");
else onInput();
});
One person, many tabs
Presence entries belong to connections, but avatars belong to people: Ben with the same file open in two tabs is two entries. The avatar stack should group entries by userId and show Ben once, with his best status (active wins over idle, idle over away), until his last connection goes. If both tabs are visible, which is rare, show only the most recently active cursor to avoid two "Ben" cursors.
Check your understanding · Exercise 4
Users report ghost cursors that stay for over a minute after someone closes their laptop. The client sends a heartbeat every 20 s. What could explain it? Select all that apply.
Scaling presence: many rooms, and big rooms
There are two different scaling problems. Many rooms: a million documents open at once, each with two or three people, which is about where room state lives. Big rooms: one document with hundreds of people, like an all-hands whiteboard or a popular public file, which is about what you send and draw at all.
Many rooms: shard by document
Presence fan-out happens inside a room, never across rooms, so the natural unit to split on is the document. If every connection for document d42 lands on the same server process, that process holds the room map in plain memory, fans out locally, and batches with a simple timer, with no shared database in the hot path.
To get there, a router in front of the presence servers picks a server from the document id, usually with consistent hashing so that adding a server moves only a small share of rooms. Figma's post on its multiplayer technology describes a similar setup: its servers run a separate process for each multiplayer document, and everyone editing that document connects to it.
Decision Where should a room's presence state live?
What we pick: For a product built around collaboration, shard by document: it matches how the data is used. For presence added to an app that already has gateways and a broker, pub/sub is fine at small room sizes. For a first version or a small team, a hosted service is often the right call.
Rebuilding after a crash is cheap because presence is ephemeral: nothing needs restoring from disk. Clients notice the dropped socket, reconnect (the router sends them to the room's new owner), and re-announce their entries. Add random jitter to the reconnect delay, or thousands of clients reconnect in the same second and knock over the new server.
Big rooms: send less, draw less
Batching keeps message counts flat, but in a room of 300 the bytes and the screen are still the problem. Three hundred moving cursors would be roughly 300 x 20 x 12 bytes = 72 KB per second per viewer for coordinates alone, and nobody can read 300 name tags anyway.
- Interest management: each client tells the server its viewport, and the server sends a cursor only to clients whose viewport contains it.
- Sampling: cap the cursors sent to each viewer, for example the 20 most recently active people in view, and send the rest as a count.
- Lower rates for crowds: past a size threshold, drop the room's tick from 20 Hz to, say, 5 Hz and lean on smoothing.
- Viewers versus editors: only presenters and active editors broadcast cursors; viewers appear in the count and avatar list only.
- Summarize the roster: show the first few avatars and "+287", and load the full list only on request.
Think first
One document goes viral and 5,000 people open it. Your presence service shards one room per process. What breaks first, and what do you do?
The single owner process for that room. Fan-out is now enormous and one CPU handles all of it, while every other room on that server suffers too. The fix is to treat very large rooms differently: past a threshold, stop broadcasting cursors from viewers, send only a count and a sampled avatar list, and keep full presence for the few people who are editing.
If the room really needs everyone's state, split the fan-out: the owner process computes one batch per tick and hands it to several edge gateways, which each forward it to their share of viewers. That turns one huge fan-out into a small one plus a tree.
Failure modes and edge cases
Presence bugs are rarely crashes. They are small lies on screen: a cursor in the wrong spot, a person shown twice, someone shown who left. Here are the ones that show up in production, and the fix for each.
| Symptom | Cause | Fix |
|---|---|---|
| Frozen cursors that never leave | No server timeout; relying on socket close | Heartbeat plus server-side timeout sweep |
| Cursor points at the wrong spot | Sending screen pixels, not document coordinates | Convert through the canvas pan and zoom before sending and after receiving |
| Selection highlights the wrong words after an edit | Selection stored as character offsets | Use the editor's stable positions (CRDT relative positions or ids) |
| Same person shown twice | Avatars keyed by connection, not user | Group by userId; one avatar per person |
| Everyone disappears, then reappears | Server restart; all clients reconnect at once | Jittered reconnect, and a short grace period before showing "left" |
| Old position overwrites a new one | Snapshot and live update crossed after reconnect | Per-entry clock; ignore updates that are not newer |
| The page gets slow in busy rooms | Re-rendering the whole tree on each cursor message | Keep cursors out of app state; move them with transforms in one animation-frame loop |
The reconnect flicker
Short network drops are common on mobile. If the server removes Ben the moment his socket closes and adds him back two seconds later, everyone sees his avatar vanish and return, and screen readers announce "Ben left" then "Ben joined". Use a grace period: on an unclean close, mark Ben as reconnecting (cursor hidden, avatar kept) for, say, 5 to 10 seconds before announcing that he left. A clean close can remove him at once.
Coordinate spaces in practice
On a canvas with pan and zoom, a pointer at screen (400, 300) with the view panned to (1000, 2000) at zoom 0.5 is at document (1000 + 400 / 0.5, 2000 + 300 / 0.5) = (1800, 2600). The receiver does the reverse with its own pan and zoom. If the result falls outside the receiver's viewport, do not hide the cursor silently: many tools show a small arrow at the edge of the screen pointing toward the person, which doubles as a "jump to" button.
Text documents need a different anchor, because text wraps differently in each window. Send the caret as a position in the text model, and let each client compute where that falls on its own layout.
Keeping rendering cheap
The worst performance mistake is putting every cursor position in a global React or Redux store. Each message then re-renders every component that reads presence, possibly 20 times per second per person.
Instead, keep the latest targets in a plain mutable map outside the framework. A single requestAnimationFrame (rAF) loop reads it and moves each cursor element with transform: translate(...), which needs no layout. Only rare changes, like someone joining, leaving, or changing status, go through normal state and re-render the avatar list.
- Give remote cursor elements
pointer-events: noneso they never steal clicks from the canvas. - Stop the rAF loop when every cursor has reached its target, and restart it when an update arrives, so an idle room costs nothing.
- Parse batches once and apply them in one pass, rather than one state update per cursor.
Check your understanding · Exercise 5
A whiteboard drops frames when 15 people are moving. Profiling shows long React commits on every presence message. Which changes help? Select all that apply.
Accessibility, security, and privacy
Live cursors are visual, but presence itself is not: a screen reader user also needs to know that Ben joined and is editing the same section. And because presence reveals what people are doing in real time, it needs the same care as any other user data.
Announce joins, not every move
Use a polite live region, an element with aria-live="polite" that screen readers read out when its text changes, for events a person would want to hear: "Ben joined", "Asha left", "Chen started editing Pricing". Never announce cursor movement: twenty announcements per second would make the page unusable.
- Batch announcements. If eight people join in the same second, say "8 people joined" once.
- Make the avatar list real content. Each avatar is a button or list item with an accessible name like "Ben, editing, page 3", not an unlabeled image. Activating it can jump to that person.
- Give remote carets a text equivalent. In text editors, expose "Ben is editing this paragraph", for example through the paragraph's description, rather than relying on a colored bar.
- Do not use color alone. Each cursor carries a name tag, in colors with enough contrast against the canvas.
- Respect reduced motion. With
prefers-reduced-motion: reduce, show cursors at their latest position without gliding, or offer a setting to hide others' cursors.
Security: presence is still user input
Every presence field comes from another person's browser, so treat it as untrusted. Render the display name in a cursor tag as text, never as HTML, or one crafted name becomes an XSS attack on everyone in the room. The server should set userId and name from the authenticated session, so nobody can appear as someone else.
- Check read access on join, the same as for the document itself.
- Validate shape and size: finite numbers for coordinates, length caps on strings, unknown fields dropped.
- Rate-limit per connection on the server, and drop (do not queue) updates over the limit.
- Cap the size of a whole presence entry, for example 1 KB, so nobody uses the channel to broadcast large payloads.
Privacy: letting people be invisible
Presence shows colleagues exactly where you are and when you are away, which can feel like surveillance. Good products let a person hide their cursor, and some let viewers of public files be anonymous ("Anonymous Otter"). Do not store presence history by default; if you need analytics, keep aggregate counts, not per-person activity trails.
In real products, and in the interview
How real tools do it
- Yjs keeps presence in a separate awareness object next to the document. Each client owns one state with a clock, renewed every 15 seconds and dropped after 30 seconds of silence. Editor bindings such as the ProseMirror and CodeMirror ones draw remote carets from it, using relative positions.
- Liveblocks offers presence as a per-room API:
updateMyPresencesends a partial update,useOthersreads everyone else's. The client throttles its messages (100 ms by default) and handles reconnects. - Figma shows avatars and named cursors and lets you click an avatar to follow that person's view. With "spotlight", a presenter asks everyone in the file to follow them, and they do unless they opt out. Its multiplayer post describes a separate server process for each multiplayer document.
- Google Docs shows colored carets with a name flag and an avatar row at the top; anonymous viewers of public docs appear as anonymous animals.
They share the pattern in this lesson: a separate ephemeral channel, a small entry per connection, throttled updates, smoothing on the receiver, and timeouts for people who vanish.
When to bring it up
Presence comes up in any "design a collaborative X" question: a doc editor, a whiteboard, a design tool, a spreadsheet, even a ticket queue ("3 other people are viewing this"). Mention it right after the document sync model, and say that it is a separate, ephemeral channel. That one sentence shows you understand the difference in rules.
What to say, by level
| Level | What a strong answer covers |
|---|---|
| Junior | Presence is separate from document data and not saved. Cursors are sent over the WebSocket, throttled to about 20 per second, and drawn with a name and color. People who close the tab disappear. |
| Mid-level | Snapshot then diffs; document coordinates; lerp smoothing in a rAF loop outside React state; heartbeat plus server timeout; Page Visibility for away; group tabs by user; live region for joins. |
| Senior | Fan-out math and server batching; sharding rooms by document id with consistent hashing; jittered reconnects and grace periods; interest management and sampling for big rooms; per-connection rate limits and server-set identity; privacy controls. |
Check your understanding · Exercise 6
In a "design Google Docs" interview, which statements about presence would a strong candidate make? Select all that apply.
Summary
- Presence is ephemeral: latest value wins, nothing is stored, a lost update is simply replaced. Keep it on its own channel, separate from document sync.
- Each tab owns one small entry. New joiners get a snapshot, then diffs; on reconnect, fetch a fresh snapshot and re-announce.
- Send positions in document coordinates and selections as stable text positions, never screen pixels or raw offsets.
- Throttle cursors to about 20 Hz with a trailing send. Fan-out grows with the square of room size, so batch per room on the server once rooms grow.
- Smooth on the receiver: lerp toward targets in a rAF loop with a time-based factor, or buffer and render slightly in the past when the path matters.
- Heartbeats plus a server-side timeout (for example 15 s and 30 s) remove ghosts; Page Visibility and an input timer mark people away or idle right away.
- For big rooms, send less: interest management, sampling, lower rates, and counts instead of cursors for viewers.
- Announce joins and leaves in a polite live region, never every move; render names as text and set identity on the server.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
How is presence different from document state, and why does it matter for the design?
Document state must be complete, ordered, and stored: losing one edit loses work, so it goes through OT or a CRDT, acknowledgments, and a database. Presence only matters now: a lost cursor update is replaced 50 ms later, and nobody wants history.
So presence gets its own channel with latest-wins rules, lives only in server memory per room, needs no acknowledgments or retries, and is wiped when a connection goes. That makes it cheap enough to update 20 times per second per person.
Question 2
Ten people are on a whiteboard. How often should a client send its cursor, and what does it cost?
Throttle to about 15 to 25 updates per second with a trailing send that always carries the latest position, send nothing while the pointer is still, and send null on pointerleave. With smoothing on the receiver, that looks nearly as good as 60 Hz.
At 20 Hz, the server receives 200 messages per second and, without batching, sends 10 x 9 x 20 = 1,800. With per-room batching every 50 ms, each client receives at most 20 messages per second regardless of room size, each holding the cursors that moved.
Question 3
Remote cursors look jumpy. Walk me through how you would make them smooth.
Separate receiving from drawing. Incoming positions become targets; a requestAnimationFrame loop moves each drawn cursor toward its target using a time-based factor, 1 - exp(-dt / tau) with tau around 80 ms, so it feels the same at 60 and 120 Hz. Cursors move with CSS transforms, outside React state.
If the exact path matters, as in a drawing tool, buffer timestamped positions and render about 1.5 intervals in the past, interpolating between the two surrounding points. Dead reckoning removes delay but overshoots when the pointer stops, so I would only use it for objects with momentum.
Question 4
How do you avoid ghost cursors when someone's laptop goes to sleep?
A sleeping laptop sends no close, so the server cannot rely on the socket. Clients send a heartbeat every 15 seconds (any update counts), the server records lastSeen with its own clock, and a sweep removes entries older than 30 seconds and tells the room.
Separately, the client uses the Page Visibility API to mark itself away and hide its cursor as soon as the tab is hidden, and an input timer to mark idle. On an unclean disconnect, a short grace period avoids leave-and-rejoin flicker for brief network drops.
Question 5
A public file has 2,000 viewers. How does presence scale?
Full fan-out would be about 2,000 x 1,999 x 20, roughly 80 million messages per second, so that is out. First, batch per tick so message counts are per client, not per sender. Then send each viewer only the cursors inside its viewport, cap that to the 20 or so most recently active, and lower the tick rate for big rooms.
Viewers who are not editing do not broadcast cursors at all; they appear as a count and a sampled avatar list. Rooms are sharded by document id, and a very hot room can hand its batches to several gateways for the last hop.
Practice it
These design problems lean on presence and live cursors.
- Design an admin CMSMedium
- Design a chat appHard
- Design DiscordHard
- Design a collaborative document editor like Google DocsHard
- Design Google SheetsHard
- Design a collaborative design tool like FigmaHard
- Design a whiteboard like ExcalidrawHard
- Design NotionHard
- Design SlackHard
- Design a customer support ticket dashboardHard
References
- Yjs docs: Awareness and presence How Yjs keeps presence next to a shared document.
- y-protocols README: the awareness protocol The clock per client state and the 30 second timeout for states that are not refreshed.
- MDN: Page Visibility API visibilityState, the visibilitychange event, and background tab policies.
- MDN: ARIA live regions How to announce joins and leaves politely.
- Chrome for Developers: Heavy throttling of chained JS timers beginning in Chrome 88 Why a heartbeat from a long-hidden tab may run only once a minute.
- Figma blog: How Figma's multiplayer technology works Server design for multiplayer documents, including one process per document.
- Liveblocks docs: Presence A hosted presence API with throttling and reconnects built in.
Read next
- Real-time collaborationHow several people edit one document at the same time: each person sees their own typing instantly, a server puts everyone's changes in one order, and every copy ends up the same.
- CRDTsConflict-free replicated data types: data structures that every device can edit on its own, offline or online, and that always merge to the same result without a central server deciding the order.
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- Operational transformation (OT)A way to merge edits made at the same time: each incoming edit is rewritten, or transformed, to account for the edits it missed, so every copy ends with the same text. Google Docs is built on it.