Skip to content

Real-time Collaboration

Y.js CRDT and TipTap integration for conflict-free text editing.


Overview

On Book Pro uses Y.js (CRDT library) with TipTap for real-time collaborative rich text editing in the Notes feature.

Key Benefits:

  • Conflict-free merging — Multiple users can edit simultaneously
  • Offline support — Changes sync when reconnected
  • Presence awareness — See who's editing in real-time

Architecture

┌─────────────────┐     ┌─────────────────┐
│   TipTap Editor │     │   TipTap Editor │
│   (User A)      │     │   (User B)      │
└────────┬────────┘     └────────┬────────┘
         │                       │
    ┌────▼────┐             ┌────▼────┐
    │  Y.Doc  │◄───────────►│  Y.Doc  │
    └────┬────┘   Sync      └────┬────┘
         │                       │
         └───────────┬───────────┘

              ┌──────────────┐
              │  Firestore   │
              │  (Persistence)│
              └──────────────┘

Y.js Setup

Creating a Y.Doc

typescript
import * as Y from 'yjs';

// Create a new Y.Doc for a note
const ydoc = new Y.Doc();

// Create a shared text type
const ytext = ydoc.getText('content');

// Create shared awareness (for presence)
const awareness = new Awareness(ydoc);

Syncing with Firestore

typescript
import { doc, onSnapshot, setDoc } from 'firebase/firestore';
import * as Y from 'yjs';
import { fromUint8Array, toUint8Array } from 'js-base64';

// Save Y.Doc state to Firestore
const saveToFirestore = async (noteId: string, ydoc: Y.Doc) => {
    const state = Y.encodeStateAsUpdate(ydoc);
    const base64 = fromUint8Array(state);
    
    await setDoc(doc(db, `notes/${noteId}`), {
        ydocState: base64,
        updatedAt: serverTimestamp(),
    });
};

// Load Y.Doc state from Firestore
const loadFromFirestore = async (noteId: string, ydoc: Y.Doc) => {
    const snapshot = await getDoc(doc(db, `notes/${noteId}`));
    if (snapshot.exists()) {
        const base64 = snapshot.data().ydocState;
        const state = toUint8Array(base64);
        Y.applyUpdate(ydoc, state);
    }
};

TipTap Integration

Editor Setup

typescript
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';

const editor = useEditor({
    extensions: [
        StarterKit,
        Collaboration.configure({
            document: ydoc,
        }),
        CollaborationCursor.configure({
            provider: awarenessProvider,
            user: {
                name: currentUser.displayName,
                color: '#f59e0b',
            },
        }),
    ],
});

Editor Component

tsx
import { EditorContent } from '@tiptap/react';

export const CollaborativeEditor = ({ noteId }: { noteId: string }) => {
    const { ydoc, awareness } = useYDoc(noteId);
    
    const editor = useEditor({
        extensions: [
            StarterKit,
            Collaboration.configure({ document: ydoc }),
            CollaborationCursor.configure({
                provider: awareness,
                user: { name: 'User', color: '#f59e0b' },
            }),
        ],
    });

    return (
        <div className="prose max-w-none">
            <EditorContent editor={editor} />
        </div>
    );
};

Presence Awareness

Show who's currently editing:

typescript
import { Awareness } from 'y-protocols/awareness';

// Update local user state
awareness.setLocalState({
    user: {
        name: currentUser.displayName,
        color: '#f59e0b',
        cursor: { anchor: 0, head: 0 },
    },
});

// Listen for other users
awareness.on('change', () => {
    const states = awareness.getStates();
    states.forEach((state, clientId) => {
        console.log(`User ${state.user?.name} at client ${clientId}`);
    });
});

Displaying Cursors

TipTap's CollaborationCursor extension renders colored cursors automatically. Style them with CSS:

css
.collaboration-cursor__caret {
    position: relative;
    margin-left: -1px;
    margin-right: -1px;
    border-left: 1px solid;
    border-right: 1px solid;
    word-break: normal;
    pointer-events: none;
}

.collaboration-cursor__label {
    position: absolute;
    top: -1.4em;
    left: -1px;
    font-size: 12px;
    font-style: normal;
    font-weight: 600;
    line-height: normal;
    padding: 0.1rem 0.3rem;
    border-radius: 3px 3px 3px 0;
    white-space: nowrap;
}

Conflict Resolution

Y.js uses CRDTs (Conflict-free Replicated Data Types) to automatically merge concurrent edits:

typescript
// User A types "Hello"
ytext.insert(0, 'Hello');

// User B types "World" at the same time
ytext.insert(0, 'World');

// Y.js automatically merges to "HelloWorld" or "WorldHello"
// based on consistent ordering rules

No Manual Merge Needed

Unlike traditional sync:

  • ❌ No merge conflicts
  • ❌ No last-write-wins data loss
  • ✅ All changes preserved

Offline Support

Y.js works offline automatically:

  1. User edits offline — Changes stored in Y.Doc
  2. User reconnects — Y.Doc syncs with Firestore
  3. Merging happens automatically — CRDT ensures consistency
typescript
// Check online status
window.addEventListener('online', () => {
    syncToFirestore(noteId, ydoc);
});

Best Practices

  1. One Y.Doc per note — Don't share docs across unrelated content
  2. Debounce Firestore saves — Don't save on every keystroke
  3. Clean up awareness — Remove local state on unmount
  4. Handle reconnection — Re-sync when coming back online

Further Reading


Last updated: January 2026