← Use cases

Collaboration: who is here, live cursors and edit locks

Show the avatars of everyone viewing a document or ticket, share cursors and focus, and broadcast saved changes so nobody overwrites anyone else.

When two people edit the same record without knowing it, one of them loses their work. Showing who else is looking at a document, a ticket or a design, and where they are, prevents that and makes a product feel shared. Project tools, CRMs, help desks, whiteboards and form builders all benefit.

What your users get

  • Avatars of everyone viewing the same page, updated as people come and go.
  • Live cursors or a highlight on the field someone is editing.
  • A clear "Ann is editing this ticket" lock instead of a conflicting save.
  • Changes from others appearing in place, without a reload.

Built with

  • Presence channels (presence-), one per document, with the member list kept correct across every node.
  • Client events (client-*) for cursor positions and focus, sent browser to browser.
  • Server events for saved changes, published after your server has stored them.
  • Large presence channels (presence-large-) when a page can have more than 100 viewers at once.

Step 1: show who is here

Authorize presence-doc-<id> the way you check access to the document itself, and return a name and avatar as user_info (see chat and typing indicators for the route). Then:

const doc = pusher.subscribe('presence-doc-' + docId);

doc.bind('pusher:subscription_succeeded', (members) => avatars.render(members));
doc.bind('pusher:member_added', () => avatars.render(doc.members));
doc.bind('pusher:member_removed', (m) => {
  avatars.render(doc.members);
  cursors.remove(m.id);
});

A user with the page open in two tabs counts once: they are added with their first socket and removed when their last one closes.

Step 2: share cursors and focus

Turn on Allow client events in the app settings. Send positions at most a few times a second, and only when they change:

let pending = null;
document.addEventListener('pointermove', (e) => {
  pending = { x: e.pageX / document.body.scrollWidth, y: e.pageY };
});
setInterval(() => {
  if (!pending) return;
  doc.trigger('client-cursor', pending);
  pending = null;
}, 200);

doc.bind('client-cursor', (pos, meta) => cursors.move(meta.user_id, pos));

// Field locks: who is editing what
field.addEventListener('focus', () => doc.trigger('client-focus', { field: field.name }));
doc.bind('client-focus', ({ field }, meta) => locks.show(field, doc.members.get(meta.user_id)));

On presence channels, the second argument of a client event handler carries the sender's user_id.

Step 3: broadcast saved changes

When someone saves, store the change, then publish it to the others:

app.patch('/docs/:id', requireLogin, async (req, res) => {
  const change = await db.docs.apply(req.params.id, req.user.id, req.body.patch);
  await pusher.trigger('presence-doc-' + req.params.id, 'changed', { version: change.version, patch: req.body.patch, by: req.user.id }, { socket_id: req.body.socket_id });
  res.json(change);
});

Other viewers apply the patch, or reload the record when their version is behind.

More than 100 viewers

Presence channels hold up to 100 members, because everyone receives the full list. For a page that many people watch, such as a company-wide announcement or a live event, use presence-large-doc-<id> with @hibira-io/large-presence: it holds up to 100,000 members and sends the count and a sample instead of every join. Client events are not allowed there, so share cursors only in smaller rooms.

Tips

  • Every client event counts as a message. Five cursor updates a second from ten editors is 50 messages a second, so send on change and slow down when the tab is hidden.
  • Keep the source of truth on your server. Client events are for hints such as cursors and focus; they are not stored and are never sent back to the sender.
  • For full collaborative text editing, send the operations of a CRDT library such as Yjs or Automerge through your server in the same way.