Real-Time Collaboration System Design — Google Docs Architecture Guide
In this tutorial, you'll learn about Real. We cover key concepts, practical examples, and best practices.
Real-time collaboration systems enable multiple users to edit shared state simultaneously with instant synchronization, using Operational Transformation (OT) or CRDT-based conflict resolution to merge concurrent edits without data loss.
Why Real-Time Collaboration Design Matters
Collaborative editing is one of the hardest problems in distributed systems. Multiple users insert and delete characters at the same position simultaneously. Without a correct algorithm, edits are lost, cursors jump, and documents corrupt. Google Docs supports millions of concurrent editors. Figma enables real-time design collaboration with 500+ users on a single file. Notion, Slab, and Coda all built collaborative editors. Doda Browser's shared bookmark lists needed real-time collaboration — using CRDTs, two users can simultaneously add, remove, and reorder bookmarks without conflicts.
Plain-Language Explanation
Imagine two people writing on the same whiteboard with different markers. If Alice writes "A" where Bob is writing "B", they collide. You need rules: either one person goes first (OT) or you design the markers so the letters merge automatically (CRDT). OT is like having a referee who decides the order. CRDT is like giving each person their own section of the whiteboard — the sections don't overlap, so there's no conflict.
graph LR
subgraph "Clients"
C1[User A]
C2[User B]
C3[User C]
end
C1 --> WS1[WebSocket]
C2 --> WS2[WebSocket]
C3 --> WS3[WebSocket]
WS1 --> SERVER[Collaboration Server]
WS2 --> SERVER
WS3 --> SERVER
SERVER --> OT[Operational Transformation
Engine]
OT --> DOC[(Document State
In-Memory)]
DOC --> SNAPSHOT[Periodic Snapshot
Database]
OT --> VER[Version Vector
/ Causality]
SERVER --> BROADCAST[Broadcast
to Other Clients]
style SERVER fill:#e67e22,color:#fff
style OT fill:#3498db,color:#fff
style DOC fill:#27ae60,color:#fff
WebSocket Connection Setup
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from typing import Set
import json
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.active: dict[str, Set[WebSocket]] = {}
async def connect(self, doc_id: str, ws: WebSocket):
await ws.accept()
if doc_id not in self.active:
self.active[doc_id] = set()
self.active[doc_id].add(ws)
def disconnect(self, doc_id: str, ws: WebSocket):
self.active[doc_id].discard(ws)
async def broadcast(self, doc_id: str, message: dict, sender: WebSocket = None):
for ws in self.active.get(doc_id, set()):
if ws != sender:
await ws.send_json(message)
manager = ConnectionManager()
@app.websocket("/ws/{doc_id}")
async def websocket_endpoint(ws: WebSocket, doc_id: str):
await manager.connect(doc_id, ws)
try:
while True:
data = await ws.receive_json()
await manager.broadcast(doc_id, data, sender=ws)
except WebSocketDisconnect:
manager.disconnect(doc_id, ws)
Operational Transformation
Transform an insert operation against a concurrent delete:
class OpInsert:
def __init__(self, pos: int, char: str):
self.pos = pos
self.char = char
def transform_against(self, other) -> 'OpInsert':
if isinstance(other, OpInsert):
if other.pos < self.pos or (other.pos == self.pos and other.char < self.char):
return OpInsert(self.pos + 1, self.char)
elif isinstance(other, OpDelete):
if other.pos < self.pos:
return OpInsert(self.pos - 1, self.char)
elif other.pos == self.pos:
return OpInsert(self.pos, self.char)
return OpInsert(self.pos, self.char)
def apply(self, doc: list) -> list:
doc.insert(self.pos, self.char)
return doc
class OpDelete:
def __init__(self, pos: int):
self.pos = pos
def transform_against(self, other):
if isinstance(other, OpInsert):
if other.pos <= self.pos:
return OpDelete(self.pos + 1)
elif isinstance(other, OpDelete):
if other.pos < self.pos:
return OpDelete(self.pos - 1)
elif other.pos == self.pos:
return None
return OpDelete(self.pos)
def apply(self, doc: list) -> list:
if 0 <= self.pos < len(doc):
doc.pop(self.pos)
return doc
CRDT-Based Character Store
A CRDT approach using unique character IDs:
import uuid
class CRDTChar:
def __init__(self, char: str, site_id: str, position: tuple):
self.id = (site_id, uuid.uuid4().hex)
self.char = char
self.position = position
class CRDTDocument:
def __init__(self, site_id: str):
self.site_id = site_id
self.chars: list[CRDTChar] = []
self.clock = 0
def insert(self, pos: int, char: str):
self.clock += 1
if pos == 0:
position = (0, self.site_id, self.clock)
elif pos >= len(self.chars):
position = (len(self.chars), self.site_id, self.clock)
else:
position = self.chars[pos - 1].position + (1,)
new_char = CRDTChar(char, self.site_id, position)
self.chars.insert(pos, new_char)
return new_char
def delete(self, pos: int):
if 0 <= pos < len(self.chars):
char = self.chars.pop(pos)
return char
return None
def get_text(self) -> str:
return "".join(c.char for c in self.chars)
doc = CRDTDocument("site-a")
doc.insert(0, "H")
doc.insert(1, "i")
doc.insert(2, "!")
print(f"Text: {doc.get_text()}")
Expected output:
Text: Hi!
Server-Side Document State
Maintain document state with version vectors:
class DocumentState:
def __init__(self, doc_id: str):
self.doc_id = doc_id
self.text = ""
self.version_vectors: dict[str, int] = {}
def apply_operation(self, site_id: str, op: dict) -> bool:
self.version_vectors[site_id] = self.version_vectors.get(site_id, 0) + 1
if op["type"] == "insert":
self.text = self.text[:op["pos"]] + op["char"] + self.text[op["pos"]:]
elif op["type"] == "delete":
if op["pos"] < len(self.text):
self.text = self.text[:op["pos"]] + self.text[op["pos"] + 1:]
return True
def to_snapshot(self) -> dict:
return {"doc_id": self.doc_id, "text": self.text, "version_vectors": self.version_vectors}
Common Mistakes
No conflict resolution strategy: Without OT or CRDT, concurrent edits cause data loss. Always implement a mathematically proven algorithm.
Server as single point of failure: If the sync server goes down, all collaboration stops. Use WebSocket reconnection with state reconciliation.
Cursor synchronization lag: Cursor positions of other users arriving late causes jarring jumps. Send cursor updates at a throttled rate (every 50-100ms).
No undo support: Undo requires inverse operations and must be handled correctly across users. Track operation history per user.
Scaling WebSocket connections: A single server handles ~10K concurrent WebSocket connections. Beyond that, use a distributed pub/sub (Redis Pub/Sub, Kafka) to fan out operations across servers.
Practice Questions
OT vs CRDT: which is better? OT is simpler for linear text but requires a central server. CRDTs work peer-to-peer, handle arbitrary merges without a server, and are used by Figma and Atom Teletype. OT is used by Google Docs.
How does Google Docs resolve conflicts? Google Docs uses OT with a central server. The server maintains document state, transforms incoming operations against concurrent ops, and broadcasts transformed ops.
What is a version vector? A mapping of site_id to operation count, used to track which operations each site has seen. Two sites have converged when their version vectors are identical.
How do you handle offline edits? Buffer operations locally and sync when reconnected. CRDTs handle this naturally — operations can be applied in any order and produce the same result.
What's the difference between WebSocket and SSE for real-time collaboration? WebSocket is bidirectional (client sends edits, receives edits). SSE is server-to-client only. Collaboration needs bidirectional, so WebSocket is mandatory.
Mini Project
Build a collaborative text editor sync core:
import asyncio, json
from fastapi import FastAPI, WebSocket
from collections import defaultdict
app = FastAPI()
doc_text = ""
doc_ops: list[dict] = []
class OTSync:
@staticmethod
def transform(op1: dict, op2: dict) -> dict:
if op1["type"] == "insert" and op2["type"] == "insert":
if op2["pos"] <= op1["pos"] or (op2["pos"] == op1["pos"] and op2.get("site", "") < op1.get("site", "")):
return {**op1, "pos": op1["pos"] + 1}
elif op1["type"] == "insert" and op2["type"] == "delete":
if op2["pos"] < op1["pos"]:
return {**op1, "pos": op1["pos"] - 1}
elif op1["type"] == "delete" and op2["type"] == "insert":
if op2["pos"] <= op1["pos"]:
return {**op1, "pos": op1["pos"] + 1}
return op1
connections: dict[str, set] = defaultdict(set)
@app.websocket("/ws/{doc_id}")
async def doc_ws(ws: WebSocket, doc_id: str):
await ws.accept()
await ws.send_json({"type": "snapshot", "text": doc_text, "ops": doc_ops[-50:]})
connections[doc_id].add(ws)
try:
while True:
data = await ws.receive_json()
doc_ops.append(data)
if data["type"] == "insert":
global doc_text
doc_text = doc_text[:data["pos"]] + data["char"] + doc_text[data["pos"]:]
elif data["type"] == "delete":
doc_text = doc_text[:data["pos"]] + doc_text[data["pos"] + 1:]
for c in connections[doc_id]:
if c != ws:
await c.send_json(data)
except:
connections[doc_id].discard(ws)
Cross-References
- Distributed Queue
- Event Sourcing
- Distributed Counter
- Consistent Hashing
- WebSocket
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro