Enable real-time collaborative editing with presence indicators, conflict-free merging, and offline mobile support.
In this guide you will configure and use real-time collaborative editing in Lifestream Vault. Multiple writers can work on the same document simultaneously, see each other's cursors and selections, and have changes merged automatically — no manual conflict resolution required.
By the end you will have:
client.collaboration resource for programmatic accessPrerequisites
On lifestreamdynamics.com collaboration is already on, so the tier is the only prerequisite. Self-hosted deployments have it on by default too — the next section covers the flags if you want to turn it off.
Collaboration is enabled by default on both the API and the web app, so a stock deployment needs no configuration — being on the Pro tier is enough. Two environment variables control it, and they must always agree: if one side is off while the other is on, the browser dials a WebSocket that was never started and retries quietly instead of reporting an error.
API environment (.env or your process manager config):
COLLAB_ENABLED=false # omit it, or set 'true', to keep collaboration on
Web environment (.env in packages/web, or Vite build flags):
VITE_COLLAB_ENABLED=false # omit it, or set 'true', to keep collaboration on
VITE_COLLAB_ENABLED is baked into the bundle when the frontend is built, so it has to be set wherever the build runs — setting it on the server does nothing. After changing either variable, restart the API and rebuild/restart the frontend:
# Update the COLLAB_ENABLED env var in your secrets backend (lsd-vault),
# then redeploy so the new value is rendered into .env on the VPS:
lsd deploy lifestream-vault
# Or, for a quick in-place change on the VPS:
ssh root@your-vps 'pm2 restart lsvault-api --update-env'
# Frontend rebuild (VITE_COLLAB_ENABLED is baked in at build time) requires
# a full deploy via lsd — the SPA is served by nginx, not PM2.When COLLAB_ENABLED is true, the API starts a WebSocket server on the same port as the REST API. No additional port configuration is required — the WebSocket upgrade is handled transparently by the same HTTP listener.
Lifestream Vault's collaborative editing stack is built on two open standards:
Yjs — a CRDT (Conflict-free Replicated Data Type) library that represents document content as a shared data structure. Any number of clients can apply edits independently, and the edits are guaranteed to converge to the same result when synced — no server arbitration needed.
y-websocket — a transport layer that broadcasts Yjs update messages between clients via a WebSocket room. Each open document maps to a room identified by {vaultId}/{docPath} (the vault's UUID and the document's path inside it, e.g. 3f2c…/notes/meeting.md) — not by a separate document ID. When you open a document, your browser (or mobile app) joins that room and begins exchanging Yjs updates with other connected clients.
The flow looks like this:
Browser A ──┐
├──► WebSocket Room (vaultId/docPath) ──► Yjs merge ──► Browser B
Browser C ──┘ └──► Browser A
The Tiptap editor on the web uses two extensions:
@tiptap/extension-collaboration — binds Tiptap's ProseMirror document to a shared Yjs XmlFragment@tiptap/extension-collaboration-cursor — broadcasts cursor position and selection via the awareness protocolThe API keeps active Yjs rooms in memory, persists the merged Markdown through the normal document service, and stores a durable canonical Yjs snapshot for restart/offline convergence. Late joiners receive the active room state; after eviction or restart, the room is restored from the canonical snapshot and reconciled with persisted Markdown. Redis provides optional cross-instance relay and locking.
The WebSocket server enforces a 1 MB maximum payload per message. This protects the server from memory pressure during large paste operations. Documents approaching this limit may need to be split. The limit applies to binary Yjs update messages, not to the raw Markdown content size.
Awareness is a lightweight Yjs protocol that runs alongside document sync. Each client broadcasts ephemeral metadata — cursor position, selection range, display name, and a colour — to all other clients in the same room. This data is not persisted; when a client disconnects, its awareness entry disappears.
What collaborators see:
displayNameColour assignment is per session: each client picks a random colour from a fixed eight-colour palette when the editor mounts, so the same person may appear in a different colour the next time they open the document. Colours are carried in awareness state only and are never persisted.
Name display uses the user's displayName field. If no display name is set, the email address is used as a fallback:
// How the awareness state is populated on the frontend
const awarenessState = {
user: {
name: currentUser.displayName ?? currentUser.email,
color: pickRandomPaletteColor(), // chosen once per editor mount
},
};
provider.awareness.setLocalStateField('user', awarenessState.user);
Awareness updates are sent over the same WebSocket connection as document updates and use a separate protocol channel. They are relayed ephemerally through Redis in multi-instance deployments, but are never persisted as durable room state.
Each user account is limited to 5 simultaneous collaboration WebSocket connections in total. A sixth connection is rejected, so count every open collaborative document across browser tabs and mobile devices.
CRDTs (Conflict-free Replicated Data Types) are the mathematical foundation that makes simultaneous editing safe. Unlike Operational Transformation (OT) — which requires a central server to serialise and transform operations — CRDTs can be merged by any client in any order and always converge to the same result.
How Yjs handles concurrent edits:
| Scenario | Result |
|---|---|
| Two users insert at the same position | Both insertions are kept; tie-broken by client ID |
| User A deletes text that User B is editing | A's deletion is applied; B's content at that position is preserved |
| Network partition — clients edit offline | Updates are buffered locally and merged on reconnect |
| User presses Ctrl-Z (undo) | Only their own changes are undone, not the other collaborator's |
Important: undo history isolation. When collaboration is active, Tiptap's built-in undo/redo (undoRedo) is disabled and replaced with Yjs's per-client undo manager. This is configured automatically when the collaboration extension is active:
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import { ydoc } from './collaboration-provider'; // your shared Yjs doc
const editor = new Editor({
extensions: [
// undoRedo: false disables Tiptap's own undo stack.
// The Collaboration extension installs its own per-client undo manager.
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: ydoc }),
],
});
If you forget to set undoRedo: false, Ctrl-Z will undo all changes in the document — including your collaborator's — which is rarely the desired behaviour.
Yjs's CRDT guarantees eventual consistency: if two clients both receive all updates (even out of order), they will end up with identical document state. This means you never need to worry about merge conflicts the way you would with Git.
The Lifestream Vault mobile app (Expo 53 / React Native 0.79) is built for offline-first editing. Documents are cached locally in expo-sqlite and edits are captured as Yjs updates. When the device reconnects, the buffered updates are flushed to the WebSocket room and merged with any concurrent server-side changes.
Offline workflow:
useCollaboration hook detects the reconnection event and sends all buffered updates to the WebSocket room.No data is lost during offline periods — Yjs's CRDT guarantees that local edits survive reconnection regardless of what happened on the server while you were disconnected.
// Simplified version of the reconnect logic in the mobile app
import NetInfo from '@react-native-community/netinfo';
import { useEffect, useRef } from 'react';
import * as Y from 'yjs';
export function useOfflineSync(ydoc: Y.Doc, provider: WebsocketProvider) {
const pendingUpdates = useRef<Uint8Array[]>([]);
// Capture updates while offline
useEffect(() => {
const handleUpdate = (update: Uint8Array, origin: unknown) => {
if (origin !== provider) {
// Local edit — buffer it
pendingUpdates.current.push(update);
}
};
ydoc.on('update', handleUpdate);
return () => ydoc.off('update', handleUpdate);
}, [ydoc, provider]);
// Flush pending updates when connectivity returns
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected && pendingUpdates.current.length > 0) {
const merged = Y.mergeUpdates(pendingUpdates.current);
provider.send(merged);
pendingUpdates.current = [];
}
});
return unsubscribe;
}, [provider]);
}Offline edits on mobile are buffered indefinitely — they are not discarded after a timeout. If a device is offline for a very long time (days or weeks), the buffered updates will still be merged on reconnect. In pathological cases this can cause a large burst of updates to the server. The 1 MB per-message limit applies here too; the mobile app chunks large update buffers into multiple messages.
The SDK exposes a client.collaboration resource that builds the WebSocket URL for a collaborative editing session. It is a pure URL builder — no HTTP call is made. Collaboration is WebSocket-only; there is no REST endpoint for querying active collaborators.
Note that the SDK does not provide a full Yjs provider. For programmatic real-time editing, use the y-websocket provider directly, pointing it at the URL from client.collaboration.getWebSocketUrl().
The WebSocket upgrade is authenticated with a JWT access token passed as the token query parameter (?token=<accessToken>). Pass it through the y-websocket params option — the server rejects the upgrade with 401 when the token is missing or invalid, and with 403 when the user lacks write access to the vault, the vault is encrypted, or the plan does not include collaboration.
// Build the WebSocket URL for a collaborative editing session
// The collaboration resource is a pure URL builder — no HTTP call is made
const wsUrl = client.collaboration.getWebSocketUrl('vault-id-here', 'notes/meeting.md');
console.log('WebSocket URL:', wsUrl);
// => 'wss://vault.lifestreamdynamics.com/collab/vault-id-here/notes/meeting.md'
// Connect a y-websocket provider using the URL:
import { WebsocketProvider } from 'y-websocket';
import * as Y from 'yjs';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://vault.lifestreamdynamics.com/collab',
'vault-id-here/notes/meeting.md',
ydoc,
{ params: { token: accessToken } }, // JWT access token — sent as ?token=
);
provider.on('status', ({ status }: { status: string }) => {
console.log('WebSocket status:', status); // 'connected' | 'disconnected'
});Before designing a workflow around real-time collaboration, be aware of the following hard limits and compatibility constraints:
| Constraint | Value / Notes |
|---|---|
| Max WebSocket payload | 1 MB per message (Yjs binary update) |
| Max collaboration connections per user | 5 simultaneous connections across the server |
| Encrypted vaults | Not compatible — the collaboration server must read and persist plaintext document content |
| Plan | Requires Pro or higher. The server rejects the WebSocket upgrade (403) for Free-plan users; for team vaults the team owner's plan is what counts |
| Team roles | Team owners, admins, and editors may join collaborative editing sessions. Viewers cannot edit at all — the server rejects their WebSocket upgrade (403), so they read the document via the normal (non-collaborative) view instead |
| Message size guard | Server drops messages exceeding 1 MB and closes the connection with code 1009 |
| Awareness data | Not persisted — disappears when a client disconnects |
undoRedo | Must be set to false on Tiptap StarterKit when collaboration is active |
| Redis relay | Optional for a single API instance; required to relay Yjs updates and awareness between multiple API instances |
| Mobile offline buffer | No timeout — updates are buffered until reconnect |
Encrypted vaults are the most significant compatibility constraint. If a vault has encryption enabled, the server cannot seed or persist the shared plaintext document, which breaks the collaboration protocol. You must disable vault encryption before enabling collaboration on that vault.
If a WebSocket message exceeds 1 MB, the server will terminate the connection with close code 1009 (Message Too Big). The client will see a disconnect event and must reconnect. To avoid this, keep individual paste operations small and avoid pasting entire large documents in a single action.
Collaborators are identified by their displayName. Encourage all team members to set a display name in Settings → Profile before collaborating. Without a display name, the system falls back to the email address, which can be long and distracting in the presence overlay.
CRDTs handle concurrent edits gracefully, but very long documents (thousands of lines) result in larger Yjs state vectors and slower initial sync for new collaborators. Consider splitting long documents into logical sections.
Share links give read-only or comment access to external users. For active co-editing — where two people are writing simultaneously — collaboration is the right tool. Share links are better for review workflows where one person edits and others comment.
Real-time collaboration is sensitive to network latency. On unreliable connections (high packet loss, mobile 3G), the awareness updates may feel laggy. The document sync itself is resilient — Yjs will buffer and merge — but the live cursor experience degrades.
Each browser tab counts as a separate connection. The 5-connection limit is global per user across all collaborative documents, and a connection beyond the limit is rejected. Close unused collaborative tabs before opening more.
There is no REST endpoint that reports active collaborators — collaboration state lives in the WebSocket room layer and, when multi-instance relay is enabled, Redis pub/sub. Coordinate out-of-band (e.g. announce a maintenance window) before running bulk SDK operations such as migrating content or reformatting, so you don't clobber a live editing session.
Single-instance collaboration keeps active Yjs rooms in the API process and does not require the Redis relay. Multi-instance deployments use Redis pub/sub to converge updates and awareness across API instances, so include Redis in monitoring whenever COLLAB_REDIS_ENABLED=true.
You now have a solid understanding of how real-time collaboration works in Lifestream Vault — from the CRDT architecture to presence indicators, offline mobile support, and the SDK integration.
Here are some places to go next:
client.collaboration, the WebSocket URL builder for collaborative sessions