How We Built Real-Time Collaboration
Building real-time collaboration for a document editor is one of the hardest problems in software engineering. Handling concurrent edits, network failures, and conflict resolution at scale requires careful architectural decisions. Here is how we did it.
The Challenge
Real-time collaboration presents several interconnected challenges:
- Concurrent edits — Multiple users typing in the same paragraph simultaneously
- Conflict resolution — Determining the correct state when edits overlap
- Network resilience — Handling offline users who reconnect with stale data
- Performance — Keeping the editor responsive with 10+ simultaneous editors
- Persistence — Saving document state reliably without data loss
Our Technology Stack
After evaluating Operational Transform (OT) and CRDTs, we chose Yjs for several reasons:
- CRDTs over OT — CRDTs guarantee eventual consistency without a central server, making them more resilient than OT.
- Yjs — A mature, battle-tested CRDT implementation with excellent ProseMirror integration.
- Hocuspocus — A WebSocket server purpose-built for Yjs that handles connection management, authentication, and persistence.
Architecture Overview
The collaboration system has three main components:
- Client (TipTap + y-prosemirror) — The editor uses y-prosemirror to bind Yjs documents to the ProseMirror state
- WebSocket Server (Hocuspocus) — Attached to the NestJS HTTP server, handles real-time sync and broadcasts changes to all connected clients
- Persistence Layer (PostgreSQL) — Yjs document state is stored as binary data in the Page model, ensuring no data is lost even if the server restarts
Authentication & Authorization
Security was non-negotiable. Every WebSocket connection goes through:
- JWT token validation on WebSocket upgrade
- Page ownership verification
- Share-based access control checking the full hierarchy: page shares, folder shares, and collection shares
- Permission level enforcement (viewer vs editor)
// Simplified Hocuspocus authentication hookonAuthenticate({ token, documentName }) {const user = verifyJwtToken(token);const pageId = extractPageId(documentName);const access = await checkAccess(user.id, pageId);if (!access.canEdit) {throw new Error('Unauthorized');}return { user, access };}
Auto-Versioning
During active collaboration, we automatically create document versions every 5 minutes. This provides a safety net — if something goes wrong, users can browse and restore previous states.
Lessons Learned
- CRDTs are not magic — While they handle concurrent edits gracefully, you still need to think carefully about how document state is structured.
- Test with real latency — Development on localhost hides timing issues. Always test collaboration with simulated network latency.
- Binary state is efficient — Storing Yjs state as binary (rather than JSON) reduces storage by 60-80% and speeds up sync.
- Graceful degradation matters — When the WebSocket disconnects, the editor should continue working offline and sync when reconnected.
Building real-time collaboration was one of the most rewarding engineering challenges we have tackled. The result is an editing experience that feels seamless — multiple people working together as if they are sitting at the same desk.