Presence and Awareness Systems
Open a Google Doc with a colleague and you see their avatar in the toolbar, their cursor moving across paragraphs, and a bubble that says they are typing. None of that is document content; it is ephemeral metadata about what a person is doing right now. That layer of awareness, knowing who is present and what they are focused on, is what makes a collaborative tool feel alive rather than like two people editing the same file in turns. Building it is a distinct engineering concern from syncing the document itself.
This guide follows realtime data synchronization and covers the ephemeral awareness layer that collaborative apps add on top of durable state sync.
What you'll learn
The awareness data model
Presence is ephemeral: it has no canonical history and does not need to survive a server restart. That shapes how it is modelled and stored.
An awareness map keyed by connection
// Awareness map: { connectionId -> AwarenessState }
var awareness = new Map();
function updateAwareness(connectionId, patch) {
var current = awareness.get(connectionId) || {};
awareness.set(connectionId, Object.assign({}, current, patch, {
lastSeen: Date.now()
}));
broadcastAwareness(connectionId);
}
// Typical awareness state shape
var exampleState = {
userId: 'user-42',
name: 'Alice',
color: '#3b82f6',
status: 'typing', // 'typing' | 'idle' | 'viewing'
cursor: { x: 420, y: 210 },
lastSeen: Date.now()
};
Keying by connection rather than user ID is intentional: the same user might have two tabs open, and each tab is an independent presence. The awareness map lives in memory on the server, not in a database, because it is fine to lose on restart. Clients rebuild it from the join broadcasts that fire when everyone reconnects after a server restart.
Online and idle detection
Knowing someone is connected is easy; knowing they are actually paying attention is harder but more useful.
Page visibility and activity events
// Track whether the tab is visible and the user is active
document.addEventListener('visibilitychange', function () {
sendAwareness({
status: document.hidden ? 'away' : 'viewing'
});
});
var idleTimer;
function resetIdle() {
clearTimeout(idleTimer);
sendAwareness({ status: 'active' });
idleTimer = setTimeout(function () {
sendAwareness({ status: 'idle' });
}, 60000);
}
document.addEventListener('mousemove', resetIdle);
document.addEventListener('keydown', resetIdle);
The Page Visibility API fires when the user switches tabs or minimises the window, giving an instant away signal without polling. Mouse and keyboard events reset an idle timer; when the timer fires with no activity the status becomes idle. These two signals together give a good three-state model: active, idle, and away, which is what most presence indicators need. Throttle the activity events, the reset function above already does by clearing and resetting rather than sending on every movement.
Typing indicators
A typing indicator is the simplest form of awareness, and the most commonly over-engineered one. The mistake is emitting an event on every keystroke.
Debounced start and stop
var typingTimer = null;
var isTyping = false;
function onInput() {
if (!isTyping) {
isTyping = true;
sendAwareness({ status: 'typing' });
}
clearTimeout(typingTimer);
typingTimer = setTimeout(function () {
isTyping = false;
sendAwareness({ status: 'viewing' });
}, 2500);
}
document.querySelector('#editor').addEventListener('input', onInput);
The pattern is: send typing once when the user starts and send stopped typing once after a pause, typically two to three seconds of no input. This reduces hundreds of messages per minute to two per typing burst. The timer threshold matters: too short and stop fires before the user finishes a sentence; too long and others see the indicator hanging after the person has moved on. Two to three seconds is the standard range.
Live cursor broadcasting
Live cursors are the most visually immediate form of awareness and the easiest to over-send. Throttling is not optional.
Throttled pointer events with coordinate normalisation
var lastCursor = null;
var CURSOR_INTERVAL = 50; // ms, ~20 fps
var sendCursor = throttle(function (x, y) {
// Normalise to 0-1 so coordinates survive window resize
var nx = x / window.innerWidth;
var ny = y / window.innerHeight;
sendAwareness({ cursor: { x: nx, y: ny } });
}, CURSOR_INTERVAL);
document.addEventListener('pointermove', function (e) {
sendCursor(e.clientX, e.clientY);
});
function throttle(fn, ms) {
var last = 0;
return function () {
var now = Date.now();
if (now - last >= ms) { last = now; fn.apply(this, arguments); }
};
}
Sending a cursor event on every pointer move fires dozens of times per second per user. Throttling to fifty milliseconds, about twenty frames per second, gives smooth-looking cursors while cutting traffic by a factor of ten or more. Normalising coordinates to the zero-to-one range before sending makes them viewport-independent, so a cursor positioned at the left edge of Alice's wide monitor maps to the same relative position in Bob's narrow window. Receivers multiply back by their own viewport dimensions.
Heartbeat-based expiry
Connections close without a clean close frame more often than you expect: laptops sleep, mobile devices switch networks, browser tabs crash. The server must clean up presence records for these ghost connections.
Server-side expiry loop
// Server: expire connections that have not pinged recently
var EXPIRY_MS = 45000; // 3x the 15s heartbeat interval
setInterval(function () {
var now = Date.now();
awareness.forEach(function (state, connId) {
if (now - state.lastSeen > EXPIRY_MS) {
awareness.delete(connId);
broadcast({ type: 'leave', connectionId: connId });
}
});
}, 15000);
Each heartbeat from the client updates lastSeen. A periodic expiry loop removes any connection whose last ping is older than three heartbeat intervals, then broadcasts a leave event so all other clients remove that user from their awareness map. Three intervals gives a reasonable grace period for a slow network without leaving ghost cursors for too long. The same loop that handles the WebSocket heartbeat can feed this timestamp.
Building the awareness layer
These individual signals compose into a coherent awareness layer that sits alongside the document sync system without coupling to it.
A self-contained awareness channel
The awareness layer works best as an independent message namespace on the same WebSocket connection. Messages tagged type: "awareness" carry ephemeral state updates and never enter the durable event log that the sync system manages. On join, the server sends a full snapshot of the current awareness map so the newcomer sees all participants immediately. After that, incremental patches flow on each change. On the client, a Backbone collection keyed by connection ID models the participant list naturally: each participant is a model with attributes for name, color, cursor, and status, and views respond to model changes to update avatars and cursor overlays without any extra glue code. The collection triggers a remove event when the server broadcasts a leave message, and the cursor overlay for that user disappears.
Frequently Asked Questions
What is a presence system?
A presence system tracks which users are currently connected and active and broadcasts that information to other participants. It powers features like online indicators, typing signals, and live cursors in collaborative tools.
How do you detect when a user goes offline?
The server tracks a last-seen timestamp for each connection, updated on heartbeat pings. A background process expires users whose last-seen is older than a threshold, typically two to three times the heartbeat interval, and broadcasts the departure to the room.
Why debounce typing indicators?
Sending a typing event on every keystroke would flood the server with messages. Debouncing sends a typing-start once when the user begins and a typing-stop after a short pause with no input, typically two to three seconds. This keeps the signal accurate while reducing traffic by orders of magnitude.
What is an awareness map?
An awareness map is a client-side data structure that holds the last-known state of every participant in the session: their name, color, cursor position, and any other ephemeral metadata. It is keyed by user or connection ID and updated as awareness events arrive.
Read next: the Interactive Web hub, or continue to realtime data synchronization for the durable state layer this awareness system complements.
Want a participant collection that updates from live presence events?
The Backbone guide shows exactly how collections model live data from a realtime source.
Explore the Backbone Guide →