Skip to main content

Collaborative Editing Architecture

HyperStudy's Experiment Designer uses WebSocket-based real-time editing, enabling multiple users to edit experiments simultaneously with instant persistence.

Overview​

The collaborative editing system replaced the previous HTTP-based auto-save approach:

AspectOld (HTTP Auto-Save)New (WebSocket)
Save mechanismDebounced HTTP POST (2s delay)Instant WebSocket emit
Payload size50-500KB (full experiment snapshot)~50 bytes (field delta)
CollaborationNot supportedReal-time multi-user
PersistenceAfter debounce timerImmediate
Conflict handlingLast-write-winsVersion tracking

Architecture​

┌─────────────────┐         ┌─────────────────┐
│ Editor A │ │ Editor B │
│ (Browser) │ │ (Browser) │
└────────┬────────┘ └────────┬────────┘
│ │
│ WebSocket │ WebSocket
│ │
▼ ▼
┌──────────────────────────────────────────────┐
│ Socket.IO Server │
│ /experimentdesigner namespace │
├──────────────────────────────────────────────┤
│ ┌────────────────┐ ┌─────────────────┐ │
│ │ Redis │ │ Firestore │ │
│ │ (versions) │ │ (data) │ │
│ └────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────┘

Data Flow​

  1. Editor makes change → marks update as local
  2. Client sends update → via WebSocket to server
  3. Server validates → checks permissions and version
  4. Server writes → to Firestore
  5. Server increments → Redis version counter
  6. Server broadcasts → to all editors in room
  7. Other editors receive → apply update
  8. Original editor ignores → echo prevention filters own update

Key Components​

Client-Side​

ExperimentDesignerSocket.js​

Manages the WebSocket connection with automatic reconnection:

// frontend/src/lib/experiment/experimentDesignerSocket.js

class ExperimentDesignerSocket {
constructor(experimentId, onUpdate, onEditorsChange) {
this.experimentId = experimentId;
this.socket = null;
this.reconnectAttempts = 0;
this.pendingUpdates = [];
}

connect() {
this.socket = io('/experimentdesigner', {
auth: { token: this.authToken }
});

this.socket.on('connect', () => {
this.socket.emit('join', { experimentId: this.experimentId });
this.flushPendingUpdates();
});

this.socket.on('update', (data) => {
// Check if this is our own update (echo)
if (!this.isOwnUpdate(data)) {
this.onUpdate(data);
}
});

this.socket.on('editors', (editors) => {
this.onEditorsChange(editors);
});

this.socket.on('disconnect', () => {
this.scheduleReconnect();
});
}

sendUpdate(field, value) {
const update = {
field,
value,
clientId: this.clientId,
timestamp: Date.now()
};

if (this.socket?.connected) {
this.socket.emit('update', update);
} else {
this.pendingUpdates.push(update);
}
}

flushPendingUpdates() {
while (this.pendingUpdates.length > 0) {
const update = this.pendingUpdates.shift();
this.socket.emit('update', update);
}
}
}

remoteUpdateApplicator.js​

Prevents echo feedback when receiving own updates:

// frontend/src/lib/experiment/remoteUpdateApplicator.js

class RemoteUpdateApplicator {
constructor() {
this.localUpdateIds = new Set();
}

// Mark an update as locally initiated
markLocal(updateId) {
this.localUpdateIds.add(updateId);
// Clean up after 5 seconds
setTimeout(() => this.localUpdateIds.delete(updateId), 5000);
}

// Check if an update should be applied
shouldApply(update) {
// Don't apply our own updates (they're already reflected in UI)
if (this.localUpdateIds.has(update.clientUpdateId)) {
return false;
}
return true;
}

// Apply a remote update to the store
applyUpdate(update, experimentStore) {
if (!this.shouldApply(update)) {
return;
}

// Apply the update to the appropriate part of the experiment
experimentStore.applyRemoteUpdate(update.field, update.value);
}
}

Server-Side​

experimentDesignerServer.js​

Socket.IO namespace handler for the experiment designer:

// backend/src/experiment/experimentDesignerServer.js

const experimentDesignerNamespace = io.of('/experimentdesigner');

experimentDesignerNamespace.on('connection', async (socket) => {
// Authenticate user
const user = await authenticateSocket(socket);
if (!user) {
socket.disconnect();
return;
}

socket.on('join', async ({ experimentId }) => {
// Check permissions (with 30s cache)
const hasAccess = await checkPermissionCached(user.uid, experimentId, 'edit');
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}

// Join the room for this experiment
socket.join(`experiment:${experimentId}`);

// Track editor
await addEditor(experimentId, user);
broadcastEditors(experimentId);

// Send current version
const version = await getVersion(experimentId);
socket.emit('version', { version });
});

socket.on('update', async (data) => {
const { experimentId, field, value, clientUpdateId } = data;

// Validate permissions
const hasAccess = await checkPermissionCached(socket.userId, experimentId, 'edit');
if (!hasAccess) {
socket.emit('error', { message: 'Access denied' });
return;
}

// Write to Firestore
await updateExperimentField(experimentId, field, value);

// Increment version in Redis
const newVersion = await incrementVersion(experimentId);

// Broadcast to all editors in room (including sender for confirmation)
experimentDesignerNamespace.to(`experiment:${experimentId}`).emit('update', {
field,
value,
version: newVersion,
clientUpdateId,
editorId: socket.userId
});
});

socket.on('disconnect', async () => {
await removeEditor(socket.experimentId, socket.userId);
broadcastEditors(socket.experimentId);
});
});

Connection Flow​

1. User opens Experiment Designer
│
2. ExperimentDesignerSocket.connect()
│
3. Socket.IO connection established
│
4. Client emits 'join' with experimentId
│
5. Server validates permissions (cached 30s)
│
6. Server joins socket to room 'experiment:{id}'
│
7. Server tracks editor, broadcasts editor list
│
8. Server sends current version
│
9. Client ready for bidirectional updates

Update Flow​

Local Update (User Types)​

1. User changes field in UI
│
2. Store update triggers sendUpdate()
│
3. remoteUpdateApplicator.markLocal(updateId)
│
4. ExperimentDesignerSocket.sendUpdate(field, value, updateId)
│
5. Server receives 'update' event
│
6. Server validates permissions
│
7. Server writes to Firestore
│
8. Server increments Redis version
│
9. Server broadcasts to room
│
10. Original client receives broadcast
│
11. remoteUpdateApplicator.shouldApply() returns false
│
12. Update ignored (already reflected in UI)

Remote Update (Collaborator Change)​

1. Server broadcasts update to room
│
2. Client receives 'update' event
│
3. remoteUpdateApplicator.shouldApply() returns true
│
4. remoteUpdateApplicator.applyUpdate()
│
5. Store updated with remote change
│
6. UI reflects change

Batch Operations​

For atomic multi-field updates (e.g., reordering states):

// Client
socket.emit('batchUpdate', {
experimentId,
updates: [
{ field: 'states.0.order', value: 1 },
{ field: 'states.1.order', value: 0 },
],
clientUpdateId
});

// Server
socket.on('batchUpdate', async ({ experimentId, updates, clientUpdateId }) => {
// Atomic Firestore batch write
const batch = db.batch();
const experimentRef = db.collection('experiments').doc(experimentId);

for (const update of updates) {
// Build the update object
}

await batch.commit();

// Single version increment
const newVersion = await incrementVersion(experimentId);

// Single broadcast
experimentDesignerNamespace.to(`experiment:${experimentId}`).emit('batchUpdate', {
updates,
version: newVersion,
clientUpdateId,
editorId: socket.userId
});
});

Permission Caching​

Permissions are cached for 30 seconds to reduce database load:

const permissionCache = new Map();

async function checkPermissionCached(userId, experimentId, action) {
const cacheKey = `${userId}:${experimentId}:${action}`;
const cached = permissionCache.get(cacheKey);

if (cached && Date.now() - cached.timestamp < 30000) {
return cached.hasAccess;
}

const hasAccess = await checkPermission(userId, experimentId, action);

permissionCache.set(cacheKey, {
hasAccess,
timestamp: Date.now()
});

return hasAccess;
}
30-Second Permission Delay

If a user is just granted edit access, they may need to wait up to 30 seconds (or refresh) for the permission to take effect due to caching.

Redis Version Tracking​

Redis tracks the current version of each experiment for cross-pod consistency:

// Get current version
async function getVersion(experimentId) {
const version = await redis.get(`experiment:${experimentId}:version`);
return parseInt(version) || 0;
}

// Increment and return new version
async function incrementVersion(experimentId) {
return await redis.incr(`experiment:${experimentId}:version`);
}

This ensures all server pods see the same version, preventing conflicts in horizontal scaling scenarios.

Editor Tracking​

The system tracks who is currently editing:

async function addEditor(experimentId, user) {
await redis.hset(
`experiment:${experimentId}:editors`,
user.uid,
JSON.stringify({
name: user.displayName,
email: user.email,
joinedAt: Date.now()
})
);
}

async function removeEditor(experimentId, userId) {
await redis.hdel(`experiment:${experimentId}:editors`, userId);
}

async function getEditors(experimentId) {
const editors = await redis.hgetall(`experiment:${experimentId}:editors`);
return Object.entries(editors).map(([uid, data]) => ({
uid,
...JSON.parse(data)
}));
}

function broadcastEditors(experimentId) {
const editors = await getEditors(experimentId);
experimentDesignerNamespace
.to(`experiment:${experimentId}`)
.emit('editors', editors);
}

Automatic Variable Sync​

When outputVariable changes on a component, variables are automatically synchronized:

socket.on('update', async (data) => {
const { field, value } = data;

// Check if this is an outputVariable change
if (field.endsWith('.outputVariable')) {
const componentPath = field.replace('.outputVariable', '');

// Get the component to find old variable name
const experiment = await getExperiment(experimentId);
const component = getByPath(experiment, componentPath);

if (component.outputVariable && component.outputVariable !== value) {
// Sync variable definition
await syncVariableDefinition(experimentId, component.outputVariable, value);
}
}

// Normal update processing...
});

Feature Flag​

The WebSocket system can be disabled for debugging:

// Disable WebSocket editing (falls back to HTTP)
localStorage.setItem('experimentDesigner.useWebSocket', 'false');

// Re-enable (default)
localStorage.removeItem('experimentDesigner.useWebSocket');

Reconnection Handling​

The client handles disconnections gracefully:

class ExperimentDesignerSocket {
scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;

setTimeout(() => {
if (!this.socket?.connected) {
this.connect();
}
}, delay);
}

flushPendingUpdates() {
// On reconnect, send any updates that were queued while offline
while (this.pendingUpdates.length > 0) {
const update = this.pendingUpdates.shift();
this.socket.emit('update', update);
}
}
}

Key Implementation Files​

FilePurpose
frontend/src/lib/experiment/experimentDesignerSocket.jsWebSocket client
frontend/src/lib/experiment/remoteUpdateApplicator.jsEcho prevention
backend/src/experiment/experimentDesignerServer.jsSocket.IO server
backend/src/services/experimentVersionService.jsRedis version tracking

Future Roadmap​

The current implementation is Phase 1 (infrastructure). Future phases may include:

  • Cursor sharing: See where collaborators are editing
  • Conflict UI: Visual indicators when edits overlap
  • Offline mode: Full offline editing with sync on reconnect
  • Presence indicators: Typing indicators and selection highlighting