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
  • Server-side flag COLLAB_ENABLED=true set in the API environment
  • Frontend flag VITE_COLLAB_ENABLED=true set in the web environment
  • Node.js 18+ for SDK usage
  • The Lifestream Vault mobile app (Expo 53 / React Native 0.79) for offline editing

Enable Collaboration

Collaboration 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:

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 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 protocol

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

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

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().

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,
);

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 connections per user per document5 simultaneous connections
Encrypted vaultsNot compatible — collaboration requires plaintext access to the Yjs state stored in Redis
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 dependencyCollaboration state is stored in Redis; the collab system is unavailable if Redis is down
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 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.

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. If you habitually open the same document in multiple tabs, you will quickly hit the 5-connection limit and start dropping older connections.

Coordinate Batch Operations with Active Editors

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.

Redis Health is Critical

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.

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