Collaborative Writing with Real-Time Editing — User Guides | Lifestream Vault

Collaborative Writing with Real-Time Editing

Enable real-time collaborative editing with presence indicators, conflict-free merging, and offline mobile support.

advanced18 min read

What You'll Build

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:

  • Real-time collaboration enabled on your instance (environment variables + flags)
  • An understanding of the Yjs CRDT architecture that powers conflict-free merging
  • Presence indicators showing who is in the document and where their cursor is
  • A solid understanding of offline editing on mobile (Expo/React Native) and how changes sync when reconnecting
  • Knowledge of the SDK client.collaboration resource for programmatic access
  • A clear picture of limitations — connection caps, payload limits, and vault compatibility
Plan Required

Prerequisites

  • A Lifestream Vault account on the Pro tier or higher
  • Node.js 22+ for SDK usage
  • The Lifestream Vault mobile app (Expo 53 / React Native 0.79) for offline editing

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.

Enable Collaboration

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:

bash
# 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.

How Real-Time Editing Works

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 protocol

The 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.

Presence and Awareness

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:

  • A coloured cursor positioned at the other writer's insertion point
  • A name badge showing the collaborator's displayName
  • A highlighted selection when the collaborator has text selected

Colour 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.

Conflict Resolution (CRDTs)

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:

ScenarioResult
Two users insert at the same positionBoth insertions are kept; tie-broken by client ID
User A deletes text that User B is editingA's deletion is applied; B's content at that position is preserved
Network partition — clients edit offlineUpdates 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.

Offline Editing on Mobile

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:

  1. You open a document while online — the app fetches the full Yjs state from the server and persists it to SQLite.
  2. You lose connectivity (airplane mode, tunnel, poor signal).
  3. You continue editing — Yjs updates accumulate in a local buffer in SQLite.
  4. Connectivity is restored — the useCollaboration hook detects the reconnection event and sends all buffered updates to the WebSocket room.
  5. The server merges the updates with any changes made by other collaborators while you were offline.
  6. The document is now consistent across all devices.

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.

typescript
// 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.

Collaboration with the SDK

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.

typescript
// 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'
});

Limitations and Constraints

Before designing a workflow around real-time collaboration, be aware of the following hard limits and compatibility constraints:

ConstraintValue / Notes
Max WebSocket payload1 MB per message (Yjs binary update)
Max collaboration connections per user5 simultaneous connections across the server
Encrypted vaultsNot compatible — the collaboration server must read and persist plaintext document content
PlanRequires 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 rolesTeam 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 guardServer drops messages exceeding 1 MB and closes the connection with code 1009
Awareness dataNot persisted — disappears when a client disconnects
undoRedoMust be set to false on Tiptap StarterKit when collaboration is active
Redis relayOptional for a single API instance; required to relay Yjs updates and awareness between multiple API instances
Mobile offline bufferNo 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.

Tips & Best Practices

Set Display Names for Clarity

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.

Use Short, Focused Documents

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.

Network Quality Matters

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.

Avoid Opening the Same Document Across Many Tabs

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.

Coordinate Batch Operations with Active Editors

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.

Monitor Redis When Multi-Instance Relay Is Enabled

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.

What's Next

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:

  • SDK Reference — full API surface for client.collaboration, the WebSocket URL builder for collaborative sessions
  • Automate Your Vault Guide — set up hooks that fire when collaborators create or update documents
  • The Lifestream Vault mobile app supports offline editing with automatic sync.
  • WebSocket-based real-time sync is handled automatically by the collaboration engine.
  • Sync with Cloud Storage — combine collaboration with cloud storage sync for a full editorial workflow