A real-time, high-concurrency collaborative editing system built with React, Spring Boot, STOMP WebSockets, Redis, and CRDTs (Conflict-free Replicated Data Types) via Yjs.
The architecture of this Collaborative Document Editor relies heavily on CRDTs to handle the complexity of merging simultaneous edits, and uses WebSockets for low-latency communication.
Here is the step-by-step flow of what happens from the moment a user opens the app to the moment they collaborate with someone else:
When User A opens the browser and loads a document (e.g., demo-doc):
- Frontend Init: The React app mounts
CollabEditorand creates an empty Yjs CRDT Document (new Y.Doc()). - REST Fetch: Before connecting to WebSockets, the frontend makes an HTTP GET request to the Spring Boot backend (
/api/documents/demo-doc). - Redis Read: The backend queries Redis for the list
doc:demo-doc:updates, which contains all historical edits ever made to this document. - State Reconstruction: The backend sends these binary chunks (encoded as Base64) back to the frontend. The frontend applies them sequentially using
Y.applyUpdate(doc, update). User A now sees the current state of the document.
Once the initial state is loaded:
- WebSocket Connect: The frontend uses
SockJSandSTOMPto open a persistent WebSocket connection to the Spring Boot backend (/ws-collab). - Topic Subscription: User A subscribes to the STOMP topic
/topic/document/demo-doc. They are now listening for any live edits made by other users.
When User A types the letter "H" into the Quill editor:
- Local Update: The
y-quillbinding immediately detects the change in the Quill editor and updates the local Yjs CRDT engine. The letter appears on screen instantly (zero latency). - CRDT Generation: Yjs generates a small, mathematically unique binary "update chunk". This chunk represents "Insert 'H' at index 5" and is tagged with User A's unique CRDT identifier.
- Transmission: Our
useCollabSynchook converts this binary chunk to Base64 and publishes a STOMP message to the backend endpoint/app/edit/demo-doc.
When the Spring Boot backend receives User A's STOMP message:
- Persistence: The
EditorControllerintercepts the message and passes it to theDocumentService. - Hot Storage: The
DocumentServicepushes the Base64 update string onto the right side of the Redis Listdoc:demo-doc:updates. This ensures that if User C joins 10 seconds later, they will get this new edit during their Initialization Flow. - Broadcasting: The
@SendToannotation on the controller automatically broadcasts the message to everyone subscribed to/topic/document/demo-doc(e.g., User B).
When User B (who is also subscribed to the topic) receives the broadcasted message:
- Decoding: User B's frontend receives the JSON message, ignores it if it was sent by themselves, and decodes the Base64 string back into a
Uint8Array. - CRDT Merging: User B passes the binary chunk into their local Yjs engine via
Y.applyUpdate(doc, remoteUpdate). - Conflict Resolution: This is where the magic happens. If User B was simultaneously typing something else, the Yjs CRDT engine uses the unique identifiers in the update chunk to deterministically figure out exactly where the "H" should go. It resolves the conflict mathematically.
- UI Update:
y-quillautomatically reflects the merged Yjs state back into User B's Quill editor.
sequenceDiagram
participant User A (Quill + Yjs)
participant Spring Boot Backend
participant Redis Database
participant User B (Quill + Yjs)
%% Initialization
User A->>Spring Boot Backend: GET /api/documents/demo-doc (Fetch History)
Spring Boot Backend->>Redis Database: LRANGE doc:demo-doc:updates
Redis Database-->>Spring Boot Backend: [Update1, Update2, ...]
Spring Boot Backend-->>User A: Base64 Array
User A->>User A: Apply Updates to local Y.Doc
%% Live Subscription
User A->>Spring Boot Backend: STOMP Subscribe /topic/document/demo-doc
%% Live Edit
User A->>User A: Types "H" (Local UI updates instantly)
User A->>Spring Boot Backend: STOMP Publish /app/edit/demo-doc (Update Chunk)
%% Backend Processing
Spring Boot Backend->>Redis Database: RPUSH doc:demo-doc:updates (Save Chunk)
Spring Boot Backend->>User B: Broadcast to /topic/document/demo-doc
%% Remote Resolution
User B->>User B: Y.applyUpdate(doc, chunk)
User B->>User B: Quill Editor updates automatically
Because of this architecture, the backend never has to parse HTML, manage cursors, or resolve text conflicts. It acts purely as a high-speed router and storage layer for binary CRDT updates, making the system incredibly scalable!
- Start Redis:
docker-compose up -d
- Start Backend (Spring Boot):
mvn spring-boot:run
- Start Frontend (Vite/React):
cd frontend npm run dev - Test: Open
http://localhost:5173in multiple browser windows to test the real-time sync.