Skip to Content

Collaborative Features

This guide explains how to implement common collaborative features using Mesh, such as shared cursors, typing indicators, and real-time document editing.

Shared Cursors

Shared cursors allow users to see where other users are currently positioned in a document or interface. This is a common feature in collaborative editing tools.

Implementation

  1. Create a cursor record for each user:
// Server-side server.exposeWritableRecord(/^cursor:user:\d+:document:\d+$/); // Client-side const userId = "123"; const documentId = "456"; const cursorRecordId = `cursor:user:${userId}:document:${documentId}`; // Initialize cursor position await client.publishRecordUpdate(cursorRecordId, { x: 0, y: 0, selection: null, timestamp: Date.now(), });
  1. Update cursor position on mouse/selection events:
document.addEventListener( "mousemove", debounce((event) => { const { clientX, clientY } = event; const { x, y } = convertToDocumentCoordinates(clientX, clientY); client.publishRecordUpdate(cursorRecordId, { x, y, selection: getSelection(), timestamp: Date.now(), }); }, 50) );
  1. Subscribe to other users’ cursors:
const { present } = await client.subscribePresence( `document:${documentId}`, async (update) => { if (update.type === "join") { const metadata = await client.getConnectionMetadata(update.connectionId); subscribeToCursor(metadata.userId); } else if (update.type === "leave") { const metadata = await client.getConnectionMetadata(update.connectionId); removeCursor(metadata.userId); } } ); for (const connectionId of present) { const metadata = await client.getConnectionMetadata(connectionId); subscribeToCursor(metadata.userId); } function subscribeToCursor(userId) { if (userId === currentUserId) return; const cursorId = `cursor:user:${userId}:document:${documentId}`; client.subscribeRecord(cursorId, (update) => { updateCursorUI(userId, update.full); }); }
  1. Render cursors in the UI:
function updateCursorUI(userId, cursorData) { const { x, y, selection, timestamp } = cursorData; let cursorElement = document.getElementById(`cursor-${userId}`); if (!cursorElement) { cursorElement = document.createElement("div"); cursorElement.id = `cursor-${userId}`; cursorElement.className = "remote-cursor"; document.body.appendChild(cursorElement); } cursorElement.style.left = `${x}px`; cursorElement.style.top = `${y}px`; if (selection) { updateSelectionUI(userId, selection); } const now = Date.now(); if (now - timestamp > 30000) { removeCursor(userId); } }

Typing Indicators

Typing indicators show when a user is currently typing, commonly used in chat applications.

Implementation

  1. Use presence state to indicate typing:
const messageInput = document.getElementById("message-input"); messageInput.addEventListener("input", () => { client.publishPresenceState("chat:room1", { state: { typing: true }, expireAfter: 5000, }); }); messageInput.addEventListener("blur", () => { client.clearPresenceState("chat:room1"); });
  1. Subscribe to typing indicators:
const { success, present, states } = await client.subscribePresence( "chat:room1", async (update) => { if (update.type === "state") { const metadata = await client.getConnectionMetadata(update.connectionId); if (update.state?.typing) { showTypingIndicator(metadata.username); } else { hideTypingIndicator(metadata.username); } } } ); for (const [connectionId, state] of Object.entries(states)) { if (state?.typing) { const metadata = await client.getConnectionMetadata(connectionId); showTypingIndicator(metadata.username); } }

Collaborative Document Editing

For collaborative document editing, you can use records to synchronize document state.

Simple Approach (Full Document Sync)

// Server-side server.exposeWritableRecord(/^document:\d+$/); // Client-side const documentId = "123"; let documentContent = ""; await client.subscribeRecord(`document:${documentId}`, (update) => { documentContent = update.full.content; updateEditor(documentContent); }); editor.addEventListener( "change", debounce(() => { const content = editor.getValue(); client.publishRecordUpdate(`document:${documentId}`, { content, lastModified: Date.now(), }); }, 500) );

Advanced Approach (Operational Transforms)

// Server-side server.exposeCommand("apply-document-operation", async (ctx) => { const { documentId, operation, baseVersion } = ctx.payload; const { value: document, version } = await server.recordManager.getRecord( `document:${documentId}` ); if (baseVersion !== version) { return { success: false, reason: "version_mismatch" }; } const newDocument = applyOperation(document, operation); await server.publishRecordUpdate(`document:${documentId}`, newDocument); return { success: true, version: version + 1 }; }); // Client-side let localOperations = []; let baseVersion = 1; editor.addEventListener("change", (change) => { const operation = convertChangeToOperation(change); applyOperation(localDocument, operation); client .command("apply-document-operation", { documentId, operation, baseVersion, }) .then((result) => { if (result.success) { baseVersion = result.version; } else { handleConflict(); } }); });

Presence Awareness

await client.joinRoom(`document:${documentId}`, async (update) => { if (update.type === "join") { const metadata = await client.getConnectionMetadata(update.connectionId); addUserToPresenceUI(metadata); } else if (update.type === "leave") { const metadata = await client.getConnectionMetadata(update.connectionId); removeUserFromPresenceUI(metadata.userId); } }); await client.publishPresenceState(`document:${documentId}`, { state: { section: "introduction", viewingPage: 1, }, });

Collaborative Drawing

// Server-side server.exposeWritableRecord(/^drawing:\d+$/); server.exposeChannel(/^drawing:\d+:strokes$/); // Client-side await client.subscribeRecord(`drawing:${drawingId}`, (update) => { initializeCanvas(update.full); }); await client.subscribeChannel(`drawing:${drawingId}:strokes`, (message) => { const stroke = JSON.parse(message); drawStroke(stroke); }); canvas.addEventListener("mousemove", (event) => { if (isDrawing) { const point = { x: event.offsetX, y: event.offsetY, pressure: event.pressure || 1, }; currentStroke.points.push(point); client.publishToChannel( `drawing:${drawingId}:strokes`, JSON.stringify({ userId, color, point, }) ); } }); canvas.addEventListener("mouseup", () => { isDrawing = false; const drawing = { ...currentDrawing }; drawing.strokes.push(currentStroke); client.publishRecordUpdate(`drawing:${drawingId}`, drawing); });

Best Practices

  1. Debounce frequent updates
    Limit updates for performance (e.g. mousemove, keystrokes)

  2. Consider bandwidth and latency
    Use minimal payloads and patch mode for large documents

  3. Handle conflicts gracefully
    Use version-aware operations or CRDTs

  4. Provide visual feedback
    Show sync state and remote activity

  5. Clean up stale data
    Use TTLs, expireAfter, or presence disconnects to clear stale cursors or state

Last updated on
© 2025