When our client approached us to build "Figma-like" collaboration into their existing design tool, we knew we were in for a serious technical challenge. The existing application was a single-user desktop experience with a codebase that wasn't originally architected for real-time collaboration. What followed was a journey of discovery, optimization, and architectural transformation that completely changed how we think about collaborative software.
I'm sharing our experience so other development teams can benefit from the hard-won lessons we learned along the way.
Real-time collaboration isn't just about sending updates back and forth. It's about creating a consistent experience for all users when they're simultaneously modifying a complex document structure, often with unreliable network connections and varying latencies.
Our key challenges included:
After evaluating several approaches, we settled on Conflict-free Replicated Data Types (CRDTs) as our foundational technology. Unlike Operational Transformation (OT), which is used in tools like Google Docs, CRDTs don't require a central authority to resolve conflicts, making them more resilient to network issues.
Here's a simplified implementation of our CRDT-based collaborative system:
class CollaborativeDesignSystem {
constructor(documentId, userId) {
this.documentId = documentId;
this.userId = userId;
this.clock = 0;
this.operations = [];
this.documentState = new DocumentState();
this.pendingOperations = new Map();
this.network = new NetworkAdapter(documentId, userId);
// Set up network handlers
this.network.onOperationReceived(this._handleRemoteOperation.bind(this));
// Set up automatic state synchronization
this.network.onConnectionStateChanged(this._handleConnectionChange.bind(this));
}
// Apply a local operation to the document
applyLocalOperation(operation) {
// Assign a logical timestamp (Lamport clock)
this.clock++;
const timestamp = this.clock;
// Create a uniquely identifiable operation
const taggedOperation = {
...operation,
id: `${this.userId}:${timestamp}`,
userId: this.userId,
timestamp
};
// Apply operation locally immediately for responsiveness
this._applyOperation(taggedOperation);
// Send to other collaborators
this.network.broadcastOperation(taggedOperation);
return taggedOperation.id;
}
// Handle incoming remote operations
_handleRemoteOperation(remoteOperation) {
// Update our logical clock to maintain causal ordering
this.clock = Math.max(this.clock, remoteOperation.timestamp) + 1;
// Apply the remote operation to our local state
this._applyOperation(remoteOperation);
}
// Apply an operation (either local or remote) to the document state
_applyOperation(operation) {
// Skip if we've already applied this operation
if (this.operations.some(op => op.id === operation.id)) {
return;
}
// Check if this operation depends on operations we haven't received yet
if (operation.dependencies) {
const missingDependencies = operation.dependencies.filter(depId =>
!this.operations.some(op => op.id === depId)
);
if (missingDependencies.length > 0) {
// Store for later application once dependencies arrive
this.pendingOperations.set(operation.id, operation);
// Request missing operations from the network
this.network.requestOperations(missingDependencies);
return;
}
}
// Process by operation type
switch (operation.type) {
case 'insert':
this._handleInsertOperation(operation);
break;
case 'update':
this._handleUpdateOperation(operation);
break;
case 'delete':
this._handleDeleteOperation(operation);
break;
case 'group':
this._handleGroupOperation(operation);
break;
case 'position':
this._handlePositionOperation(operation);
break;
// Additional operation types...
}
// Record that we've applied this operation
this.operations.push(operation);
// Check if any pending operations can now be applied
this._processPendingOperations();
}
// Process any pending operations whose dependencies are now met
_processPendingOperations() {
let appliedAny = false;
this.pendingOperations.forEach((operation, id) => {
const missingDependencies = operation.dependencies.filter(depId =>
!this.operations.some(op => op.id === depId)
);
if (missingDependencies.length === 0) {
this.pendingOperations.delete(id);
this._applyOperation(operation);
appliedAny = true;
}
});
// Recursively process until no more can be applied
if (appliedAny && this.pendingOperations.size > 0) {
this._processPendingOperations();
}
}
// Handle specific operation types
_handleInsertOperation(operation) {
const { elementType, properties, parentId, position } = operation.data;
// Create element with a globally unique ID
const elementId = operation.id; // Using the operation ID as the element ID
// Add to document state
this.documentState.createElement(elementId, elementType, properties);
this.documentState.addToParent(elementId, parentId, position);
// Notify UI of changes
this._notifyUIChanges([{
type: 'elementAdded',
elementId,
parentId,
position
}]);
}
_handleUpdateOperation(operation) {
const { elementId, properties, propertyPath } = operation.data;
// Apply property updates
if (propertyPath) {
// For deep property updates
this.documentState.updateElementNestedProperty(elementId, propertyPath, properties);
} else {
// For shallow property updates
this.documentState.updateElementProperties(elementId, properties);
}
// Notify UI of changes
this._notifyUIChanges([{
type: 'elementUpdated',
elementId,
properties,
propertyPath
}]);
}
_handleDeleteOperation(operation) {
const { elementId } = operation.data;
// Logical deletion (mark as deleted rather than removing)
this.documentState.markAsDeleted(elementId);
// Notify UI of changes
this._notifyUIChanges([{
type: 'elementDeleted',
elementId
}]);
}
_handlePositionOperation(operation) {
const { elementId, x, y, strategy } = operation.data;
// Apply position - This is where CRDT position logic happens
if (strategy === 'absolute') {
// Simple positioning
this.documentState.setElementPosition(elementId, x, y);
} else if (strategy === 'fractional') {
// Fractional positioning (more resilient to concurrent edits)
this.documentState.setElementFractionalPosition(elementId, x, y);
}
// Notify UI of changes
this._notifyUIChanges([{
type: 'elementMoved',
elementId,
position: { x, y }
}]);
}
// Utility methods and connection handling...
}
One of the trickiest parts of collaborative design is handling element positioning. If two users simultaneously move the same element, how do you resolve that conflict? We solved this with a fractional positioning system inspired by the CRDT approach:
class FractionalPositioning {
// Generate a position between two other positions
static generatePositionBetween(before, after) {
// If no positions exist yet
if (!before && !after) {
return [0.5];
}
// If inserting at the beginning
if (!before) {
// Position before the first element
const firstDigit = after[0];
if (firstDigit > 0) {
return [firstDigit / 2];
} else {
return [0, after[1] ? after[1] / 2 : 0.5];
}
}
// If inserting at the end
if (!after) {
// Position after the last element
return [before[0] + 1];
}
// Find a position between before and after
const beforeArr = Array.isArray(before) ? before : [before];
const afterArr = Array.isArray(after) ? after : [after];
// Try to insert between them
return this._positionBetween(beforeArr, afterArr);
}
// Core algorithm to find a position between two others
static _positionBetween(before, after) {
// Compare elements at each level of the arrays
for (let i = 0; i < Math.max(before.length, after.length); i++) {
const beforeVal = i < before.length ? before[i] : 0;
const afterVal = i < after.length ? after[i] : 0;
if (beforeVal !== afterVal) {
// Found a level where they differ, insert between these values
const midValue = (beforeVal + afterVal) / 2;
if (midValue !== beforeVal && midValue !== afterVal) {
// Simple case: average is between the values
const result = before.slice(0, i);
result.push(midValue);
return result;
} else {
// Precision limit reached, need to add another level
const result = before.slice(0, i + 1);
result.push(0.5);
return result;
}
}
}
// If we get here, the arrays are identical
// Add another level
const result = before.slice();
result.push(0.5);
return result;
}
// Convert a fractional position to a decimal for UI rendering
static toDecimal(fractionalPosition) {
let result = 0;
let divisor = 1;
for (const value of fractionalPosition) {
result += value / divisor;
divisor *= 10;
}
return result;
}
}
This approach allowed us to generate unique positions between existing elements that would be consistently ordered regardless of which user created them, avoiding the need for conflict resolution entirely.