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
COLLAB_ENABLED=true set in the API environmentVITE_COLLAB_ENABLED=true set in the web environmentCollaboration is disabled by default. To activate it you must set two environment variables — one for the API process, one for the frontend build — and then restart both services.
API environment (.env or your process manager config):
COLLAB_ENABLED=true
Web environment (.env in packages/web, or Vite build flags):
VITE_COLLAB_ENABLED=true
After setting both variables, 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 its documentId. 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 (documentId) ──► 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's WebSocket server stores the latest Yjs document state in Redis so late-joining clients receive the full document without replaying every historical update.
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 deterministic: each client picks a colour by hashing the user's ID against a fixed palette, so the same person always appears in the same colour across sessions.
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: deriveColorFromUserId(currentUser.id),
},
};
provider.awareness.setLocalStateField('user', awarenessState.user);
Awareness updates are sent over the same WebSocket connection as document updates, but they travel in a separate protocol channel and are never written to Redis.
Each user account is limited to 5 simultaneous WebSocket connections per document room. Opening the same document in more than 5 browser tabs (or a mix of browser + mobile) will cause the oldest connection to be dropped. Design multi-device workflows accordingly.
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().
// 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,
);
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 connections per user per document | 5 simultaneous connections |
| Encrypted vaults | Not compatible — collaboration requires plaintext access to the Yjs state stored in Redis |
| 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 dependency | Collaboration state is stored in Redis; the collab system is unavailable if Redis is down |
| 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 read or store the Yjs state, 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. If you habitually open the same document in multiple tabs, you will quickly hit the 5-connection limit and start dropping older connections.
There is no REST endpoint that reports active collaborators — collaboration state lives entirely in the WebSocket/Redis layer. 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.
The collaboration system depends entirely on Redis for Yjs state storage and awareness relay. Include Redis in your monitoring stack — an unhealthy Redis instance will silently break collaboration for all users without affecting the REST API.
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