Target Level: Senior Frontend Engineer / Staff Engineer
Duration: 45-60 minutes
Interview Focus: Real-time Collaboration, Conflict Resolution, State Synchronization, Performance
Interview Importance: π΄ Critical β Collaborative editing questions test state synchronization, concurrency control, offline recovery, and product UX trade-offs all at once, which makes them a classic senior frontend design problem.
When asked to design Google Docs in a frontend interview, interviewers are evaluating:
Pro Tip: Start by clarifying the scope, then dive into the collaborative editing algorithm (OT/CRDT), followed by architecture and implementation details.
Before diving in, ask these questions to scope the problem:
Functional Scope:
Non-Functional Requirements:
Technical Constraints:
Loading diagramβ¦
Key Talking Points:
Interview Answer:
"For real-time collaboration, we need to handle concurrent edits from multiple users. There are two main approaches:
Operational Transformation (OT):
- β Smaller bandwidth (sends operations, not full state)
- β Google Docs uses this
- β Complex: requires transform functions for every operation pair
- β Central server required for operation ordering
Conflict-Free Replicated Data Types (CRDT):
- β Simpler: mathematically proven to converge
- β Works peer-to-peer (no central server)
- β Better offline support
- β Larger payload size (contains metadata)
- β Examples: Yjs, Automerge
For Google Docs scale, I'd use OT since it's bandwidth-efficient and we have a central server anyway."
Interview Answer:
"OT transforms operations so they can be applied in any order and still converge to the same result. Here's a simple example:
Initial state:
'Hello'User A: Inserts
'!'at position 5 ->'Hello!'User B: Inserts'World'at position 5 ->'HelloWorld'When User A receives User B's operation:
- Original operation: Insert 'World' at position 5
- Transform: Since User A already inserted at position 5, we need to shift User B's operation
- Transformed operation: Insert 'World' at position 6
- Final state:
'Hello!World'Both users converge to the same state regardless of operation order."
Code Example:
class Operation { constructor(type, position, content) { this.type = type; // 'insert' or 'delete' this.position = position; this.content = content; this.clientId = null; this.version = null; this.id = Math.random().toString(36).slice(2, 11); // Unique operation ID } } // Transform operation B against operation A // Returns the transformed version of opB that accounts for opA being applied first const transform = (opA, opB) => { if (opA.type === 'insert' && opB.type === 'insert') { if (opA.position < opB.position) { // A inserts before B, shift B's position right return new Operation( opB.type, opB.position + opA.content.length, opB.content ); } else if (opA.position > opB.position) { // A inserts after B, B is unaffected return opB; } else { // Same position - use client ID for deterministic ordering if (opA.clientId < opB.clientId) { return new Operation( opB.type, opB.position + opA.content.length, opB.content ); } return opB; } } if (opA.type === 'delete' && opB.type === 'insert') { if (opB.position <= opA.position) { // B inserts before deletion, B is unaffected return opB; } else if (opB.position >= opA.position + opA.content.length) { // B inserts after deletion, shift B's position left return new Operation( opB.type, opB.position - opA.content.length, opB.content ); } else { // B inserts within deleted range, adjust to deletion point return new Operation( opB.type, opA.position, opB.content ); } } // Add more cases for delete-delete, etc. return opB; };
Interview Answer:
"We have two options for rendering:
ContentEditable (Browser Native):
- β Built-in cursor, selection, input handling
- β Accessibility for free
- β Inconsistent across browsers
- β Hard to control (browser can insert
<span>,<div>, etc.)- β Difficult to reconcile with our document model
Custom Canvas Rendering:
- β Complete control over rendering
- β Consistent across browsers
- β Must implement cursor, selection, IME from scratch
- β Accessibility is complex
Google Docs uses a hybrid approach: ContentEditable for input capture, but the visual rendering is custom. They intercept mutations and convert to operations, then re-render from the document model."
Code Example:
class DocumentModel { constructor(initialText = '') { this.content = initialText; this.operations = []; // Operation log for OT this.version = 0; this.cursors = new Map(); // clientId -> position } applyOperation(operation) { switch(operation.type) { case 'insert': this.content = this.content.slice(0, operation.position) + operation.content + this.content.slice(operation.position); break; case 'delete': this.content = this.content.slice(0, operation.position) + this.content.slice(operation.position + operation.content.length); break; } this.operations.push(operation); this.version++; } getOperationsSince(version) { return this.operations.slice(version); } } // Example usage const doc = new DocumentModel('Hello'); // Local user types const op1 = new Operation('insert', 5, ' World'); doc.applyOperation(op1); console.log(doc.content); // 'Hello World' // Remote user's operation arrives const op2 = new Operation('insert', 0, 'Say '); doc.applyOperation(op2); console.log(doc.content); // 'Say Hello World'
Interview Answer:
"We need a reliable protocol for sending operations between clients and server:
Message Types:
OPERATION- Client sends edit operationOPERATION_ACK- Server acknowledges receipt (with server version)OPERATION_BROADCAST- Server sends operation to other clientsCURSOR_UPDATE- Client sends cursor positionPRESENCE_JOIN- User joins documentPRESENCE_LEAVE- User leaves documentSNAPSHOT- Full document state (for new clients)Flow:
- Client makes local edit -> Apply optimistically
- Send operation to server via WebSocket
- Server receives -> Transform against concurrent operations -> Persist -> Broadcast
- Other clients receive -> Transform against local pending operations -> Apply
- Original client receives ACK -> Commit operation (remove from pending queue)"
Code Example:
class SyncEngine { constructor(documentId, userId) { this.documentId = documentId; this.userId = userId; this.ws = null; this.doc = new DocumentModel(); this.pendingOps = []; // Operations not yet acknowledged this.serverVersion = 0; this.reconnectAttempts = 0; } connect() { this.ws = new WebSocket(`wss://api.example.com/docs/${this.documentId}`); this.ws.onopen = () => { console.log('Connected to document'); this.reconnectAttempts = 0; // Request initial snapshot this.ws.send(JSON.stringify({ type: 'SNAPSHOT_REQUEST', version: this.serverVersion })); }; this.ws.onmessage = (event) => { const message = JSON.parse(event.data); this.handleMessage(message); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; this.ws.onclose = () => { console.log('Disconnected from document'); this.reconnect(); }; } handleMessage(message) { switch(message.type) { case 'SNAPSHOT': this.doc = new DocumentModel(message.content); this.serverVersion = message.version; this.renderDocument(); break; case 'OPERATION_BROADCAST': this.handleRemoteOperation(message.operation); break; case 'OPERATION_ACK': this.handleAcknowledgement(message); break; case 'CURSOR_UPDATE': this.updateRemoteCursor(message.clientId, message.position); break; case 'PRESENCE_JOIN': this.handleUserJoin(message.user); break; case 'PRESENCE_LEAVE': this.handleUserLeave(message.userId); break; } } // User makes local edit handleLocalOperation(operation) { // 1. Apply optimistically to local document this.doc.applyOperation(operation); this.renderDocument(); // 2. Add to pending queue operation.clientId = this.userId; operation.baseVersion = this.serverVersion; this.pendingOps.push(operation); // 3. Send to server if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'OPERATION', operation: operation })); } else { // Offline - will sync when reconnected this.saveToOfflineQueue(operation); } } // Server broadcasts another user's operation handleRemoteOperation(operation) { // Transform against all pending local operations let transformed = operation; for (const pendingOp of this.pendingOps) { transformed = transform(pendingOp, transformed); } // Apply transformed operation this.doc.applyOperation(transformed); this.serverVersion++; this.renderDocument(); } // Server acknowledges our operation handleAcknowledgement(message) { const { operationId, version } = message; // Remove from pending queue this.pendingOps = this.pendingOps.filter( op => op.id !== operationId ); this.serverVersion = version; } reconnect() { this.reconnectAttempts++; const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); console.log(`Reconnecting in ${delay}ms...`); setTimeout(() => this.connect(), delay); } renderDocument() { // Update editor display (implementation depends on UI framework) document.getElementById('editor').textContent = this.doc.content; } async getDB() { // Initialize and return IndexedDB connection if (this.db) return this.db; return new Promise((resolve, reject) => { const request = indexedDB.open('GoogleDocsOffline', 1); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this.db); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('operations')) { db.createObjectStore('operations', { autoIncrement: true }); } }; }); } async saveToOfflineQueue(operation) { // Store in IndexedDB for offline support const db = await this.getDB(); const tx = db.transaction(['operations'], 'readwrite'); tx.objectStore('operations').add({ documentId: this.documentId, operation: operation, timestamp: Date.now() }); } }
Answer: Choose OT when you have a central coordination server, want smaller payloads, and need predictable ordering for large collaborative documents. CRDT is attractive when offline-first or peer-to-peer collaboration is the primary requirement, but it usually carries more metadata overhead.
Answer: Apply operations optimistically to the local model, queue them for sync, and reconcile later with ACKs plus transformation against remote operations. That keeps keystrokes immediate while preserving convergence once the server catches up.
Answer: Rendering and cursor bookkeeping usually break before the sync algorithm does. You need virtualization, incremental layout work, and careful presence updates so the editor stays responsive even when the operation log keeps growing.
What to emphasize:
What to avoid:
Sample closing statement:
"To summarize, I'd build a real-time collaborative editor using Operational Transformation for conflict-free editing, WebSocket for low-latency synchronization, and IndexedDB for offline support. The key challenges are maintaining consistency across clients, handling network partitions, and optimizing performance for large documents. I'd use virtualization for rendering, batching for network efficiency, and comprehensive monitoring to ensure <100ms sync latency. The architecture separates UI, document model, and sync engine for maintainability."
| Operation | Time Complexity | Space Complexity | Why it matters |
|---|---|---|---|
| Apply local insert/delete | O(n) | O(n) | Array or string-backed editors shift content as document size grows |
| Transform remote op against pending ops | O(p) | O(p) | p is the count of unacknowledged local operations |
| Rebuild visible editor slice | O(v) | O(v) | Virtualization keeps work proportional to visible content instead of full document size |
| Replay offline queue | O(q) | O(q) | Sync cost grows with queued operations after reconnect |
Libraries:
Papers:
Open Source Examples:
Pro Tips for the Interview:
Good luck with your interview! π
Test your understanding with 3 quick questions