system-design59 min read

How to Design a Collaborative Editing System — A Senior+ Guide | Ayodhyya

How to Design a Collaborative Editing System

Building a Production-Grade Real-Time Document Collaboration Platform — Conflict Resolution, Sync, and Scale

Senior+ System Design Guide 10,000+ Words 22 Deep-Dive Sections C# · Mermaid · Real-World Case Studies

1. Introduction & Why Collaborative Editing is Hard

Collaborative editing — the ability for multiple users to simultaneously edit the same document in real-time — is one of the most complex distributed systems problems in modern software engineering. Google Docs, Notion, Figma, and hundreds of other tools have made real-time collaboration an expectation rather than a luxury. Yet underneath the deceptively simple user experience of "typing together" lies a labyrinth of concurrency control, conflict resolution, network synchronization, and consistency guarantees that rival the complexity of distributed databases.

The core difficulty stems from a fundamental tension: every user's client maintains a local copy of the document state, and each user modifies that state independently and concurrently. When two users type at the same position simultaneously, or one user deletes a paragraph while another is editing it, or a user goes offline for an hour and then reconnects with a stale view of the document — the system must reconcile all these divergent states into a single consistent document that every participant agrees on. This is not merely an academic exercise; incorrect conflict resolution leads to data loss, text corruption, or user frustration, all of which are unacceptable in production systems.

The problem becomes exponentially harder when you account for the realities of global-scale distributed systems: network partitions are not exceptional events but everyday occurrences, mobile clients routinely lose connectivity for minutes or hours, documents can range from a single paragraph to thousands of pages with embedded media, and the system must deliver sub-100ms latency for keystroke-to-remote-display to maintain the illusion of real-time collaboration. Google's operational transformation engine, which powers Google Docs, processes over 2 billion character changes per day across billions of documents. Figma's multiplayer engine synchronizes vector graphics state across thousands of concurrent designers on a single file. These systems represent some of the most sophisticated distributed software ever built.

Key Insight: Collaborative editing is fundamentally a distributed consensus problem where the "value" being agreed upon is the content of a document. Unlike a key-value store where you can use Paxos or Raft to agree on a single value, collaborative editing requires agreeing on the total ordering of an unbounded stream of operations — while maintaining low latency, supporting offline editing, and providing intuitive conflict resolution to non-technical users.

Real-World Case Studies

ProductApproachScaleKey Innovation
Google DocsOperational Transformation (OT)2B+ char changes/dayServer-authoritative OT with centralized transform
FigmaCustom CRDT-likeMillions of concurrent editorsBinary CRDT for vector graphics, region-based locking
NotionCRDT (Yjs)30M+ usersBlock-level CRDT, offline-first with sync
Slack (Canvas)CRDTHundreds of millions of usersAutomerge-based, integrated with messaging
Apple iWorkCustom OTHundreds of millions of devicesPeer-to-peer sync via iCloud, conflict-free replication
LiveblocksCRDT (Yjs-based)Thousands of appsManaged collaboration infrastructure, presence API

2. Functional & Non-Functional Requirements

Before diving into the architecture, let us establish clear requirements. A production collaborative editing system must satisfy both functional requirements (what it does) and non-functional requirements (how well it does it). These requirements drive every architectural decision in the system.

Functional Requirements

  • Real-time collaborative editing: Multiple users can simultaneously edit the same document with keystroke-level synchronization. Changes from any user appear on all other users' screens within 100ms under normal network conditions.
  • Conflict-free convergence: All users' views of the document must converge to the same state regardless of the order in which operations arrive. No user should ever see corrupted text, duplicated content, or lost edits.
  • Cursor and selection presence: Each user can see the cursors and text selections of all other active collaborators, identified by name or avatar, with smooth animation as cursors move.
  • Offline editing and sync: Users can continue editing while disconnected. When they reconnect, their changes are merged with any changes made by others during the disconnection period.
  • Document versioning: Complete history of all changes with the ability to view, compare, and restore any previous version of the document.
  • Undo/redo: Each user can undo and redo their own changes independently without affecting other users' edits or cursor positions.
  • Commenting and annotations: Users can add comments to specific text ranges, reply to comments, resolve threads, and @mention other users.
  • Granular permissions: Document owners can set permissions at the document, section, or block level — read-only, comment-only, or full edit access.
  • Rich content embedding: Support for images, videos, tables, code blocks, embeds, and other rich media within documents.
  • Plugin system: Third-party developers can extend document functionality with custom blocks, slash commands, and integrations.

Non-Functional Requirements

  • Latency: Keystroke-to-remote-display latency under 100ms (P95) for users in the same geographic region. Under 200ms (P95) for cross-region collaboration.
  • Concurrency: Support 50+ simultaneous editors per document and 100,000+ concurrent documents across the system.
  • Durability: Zero data loss. Every acknowledged edit must be persisted and recoverable, even in the event of server crashes or network failures.
  • Availability: 99.99% uptime for document read access. 99.9% uptime for real-time collaboration features. Graceful degradation when collaboration servers are unavailable.
  • Scalability: Horizontal scaling to support millions of concurrent users across thousands of documents. Linear cost scaling with document size and user count.
  • Security: End-to-end encryption option for sensitive documents. SOC 2 and GDPR compliance. Role-based access control with audit logging.
  • Consistency: Strong eventual consistency — all replicas converge to the same state after all operations are delivered, and the converged state is always a valid document.
Trade-Off Alert: The tension between latency and consistency is the defining challenge of this system. Strong consistency (every user sees the same state at the same time) requires synchronous coordination, which adds latency. Eventual consistency allows lower latency but requires sophisticated conflict resolution. Our system chooses strong eventual consistency — we guarantee convergence but not instantaneous agreement.

3. OT vs CRDT — The Fundamental Trade-Off

The two dominant approaches to conflict-free collaborative editing are Operational Transformation (OT) and Conflict-free Replicated Data Types (CRDT). Understanding the trade-offs between these approaches is the single most important architectural decision in designing a collaborative editing system. Both approaches guarantee convergence — that all clients will eventually see the same document state — but they achieve this through fundamentally different mechanisms with different trade-offs.

Operational Transformation (OT)

OT was pioneered by Ellis and Gibbs in 1989 and refined by Nicholas Charrière for the Google Wave project, which eventually became the foundation for Google Docs. In OT, each user action (insert character, delete character, format text) is represented as an operation. When a client generates an operation, it sends the operation to a central server. The server applies the operation to the authoritative document state and then transforms any concurrent operations against the newly applied operation to maintain consistency.

The key mechanism in OT is the transform(op1, op2) function. When two operations op1 and op2 arrive at the server concurrently (meaning neither has seen the other), the transform function produces two new operations: op1' = transform(op1, op2) and op2' = transform(op2, op1). The invariant is: apply(apply(state, op1), op2') == apply(apply(state, op2), op1'). This ensures that regardless of the order in which operations are applied, the resulting state is identical. The transform function for text operations involves shifting insertion and deletion positions based on the concurrent operations' effects on the document.

OT has several well-known advantages. It is a mature technology with decades of academic research and production deployment. The server-authoritative model means there is a single source of truth, which simplifies reasoning about consistency. It works well with central server architectures and handles complex operations (formatting, structural changes) elegantly. However, OT has significant disadvantages. The transform function is notoriously difficult to implement correctly — the "tp1 problem" (correctly handling an arbitrary number of concurrent operations) has been the source of numerous bugs in production systems. OT typically requires a central server to coordinate transformations, making peer-to-peer architectures challenging. The complexity of the transform function grows quadratically with the number of concurrent operation types.

Conflict-free Replicated Data Types (CRDT)

CRDTs were formalized by Shapiro et al. in 2011 and have gained enormous popularity in the last decade. A CRDT is a data structure that can be replicated across multiple computers, updated independently and concurrently, and merged without requiring coordination or conflict resolution. CRDTs guarantee convergence through mathematical properties of the data structure itself — specifically, they operate on a join-semilattice with a commutative, associative, and idempotent merge operation.

For text editing, the most common CRDT approach is the sequence CRDT, such as LSEQ, Logoot, or the more recent Yjs and Automerge implementations. Each character in the document is assigned a unique, globally ordered identifier (a "logical timestamp" or position identifier). When a character is inserted, it is placed between two existing position identifiers using an algorithm that generates a new identifier that is guaranteed to fall between them, even if two users insert at the same position concurrently. When replicas merge, characters are sorted by their position identifiers, and the resulting document is deterministic regardless of the order in which insertions were received.

CRDTs have compelling advantages for collaborative editing. They do not require a central server — replicas can sync peer-to-peer and merge without coordination. This makes them ideal for offline-first architectures and distributed systems. The merge operation is defined by the data structure itself, eliminating an entire class of bugs related to incorrect transform function implementations. CRDTs naturally support offline editing because operations can be generated locally and merged later. However, CRDTs have their own challenges. Position identifiers can grow very long in large documents (logarithmic or linear growth depending on the algorithm), increasing memory and bandwidth usage. Garbage collection of tombstones (markers for deleted characters) adds complexity. Rich formatting and structural operations (not just character insertion/deletion) require more complex CRDT designs.

DimensionOTCRDT
ArchitectureCentral server (typically)Peer-to-peer or server-assisted
CoordinationServer-authoritativeCoordination-free
Offline supportLimited (requires server reconnection)Native (merge on reconnect)
Implementation complexityHigh (transform functions)Medium (data structure design)
Memory overheadLow (operations applied in order)Higher (position identifiers, tombstones)
Bandwidth efficiencyHigh (compact operation encoding)Medium (position identifiers transmitted)
Undo/redoComplex (multi-user aware)Natural (operation-based)
Proven at scaleGoogle Docs, Apple iWorkFigma, Notion, Slack
Our Choice: For this system design, we adopt a CRDT-based approach using a Yjs-inspired architecture. The reasons are compelling: native offline support, simpler correctness guarantees, and the growing ecosystem of CRDT tooling. We supplement the CRDT with a server-side "presence server" for low-latency cursor synchronization and a persistent storage layer for document versioning. This hybrid approach gives us the best of both worlds — CRDT for document state convergence and server infrastructure for presence, persistence, and access control.

4. Conflict Resolution Strategies

Conflict resolution in collaborative editing goes beyond the mathematical convergence guarantee of OT or CRDT. Users have intuitive expectations about how conflicts should be resolved — expectations that are not always aligned with what the underlying algorithm produces. A sophisticated collaborative editing system must handle conflicts at multiple levels: character-level convergence (handled by OT/CRDT), semantic-level conflicts (e.g., two users moving the same paragraph to different locations), and user-experience conflicts (e.g., one user's carefully formatted table being overwritten by another user's paste operation).

Character-Level Conflict Resolution

At the most fundamental level, character-level conflicts occur when two users simultaneously insert or delete the same content. The CRDT handles this automatically: two concurrent insertions at the same position both succeed, with each character assigned a unique position identifier that determines the final order. Two concurrent deletions of the same character both succeed (the character is deleted regardless of the order). An insertion and a deletion at the same position result in the deletion winning (the character is removed and the insertion is effectively a no-op on a deleted region). This behavior is intuitive and well-understood.

Structural Conflict Resolution

Structural conflicts are more complex. Consider a block-based document (like Notion) where two users simultaneously move the same block to different positions. The CRDT must decide where the block ends up. Our system resolves structural conflicts using a "last-writer-wins" (LWW) approach at the block level, where the move operation with the higher logical timestamp takes precedence. Alternatively, we can use a "split" strategy where the block is duplicated in both target locations, and the user is notified to resolve the ambiguity manually. The choice depends on the document model and user expectations.

Formatting Conflict Resolution

Formatting conflicts occur when two users apply different formats to the same text range simultaneously. For example, User A bolds a paragraph while User C italicizes it. The intuitive result is that the text should be both bold and italic — formatting operations should be additive, not exclusive. Our CRDT-based formatting model treats each formatting attribute (bold, italic, underline, color, etc.) as an independent CRDT value, so concurrent formatting operations on the same range are merged additively. This is a significant advantage of CRDTs over OT for rich text editing.

graph TD A[User Operation] --> B{Conflict Type?} B -->|Character Insert/Delete| C[CRDT Sequence Merge] B -->|Block Structure Move| D[LWW or Split Strategy] B -->|Formatting| E[Additive Merge per Attribute] B -->|Comment/Annotation| F[Independent CRDT per Thread] C --> G[Converged Document State] D --> G E --> G F --> G G --> H[Broadcast to All Clients]

Intention Preservation

A critical principle in conflict resolution is intention preservation. When a user performs an operation, the system should preserve the user's intent as closely as possible, even when that operation conflicts with others. For example, if a user selects "hello world" and types "bye", their intent is to replace "hello world" with "bye". If another user simultaneously inserts text after "hello", the first user's replacement should still result in "bye" appearing in the document, with the other user's insertion occurring at an appropriate position. This is trivially handled by CRDTs because the delete range and insert operation are independent.

However, intention preservation becomes more complex with compound operations. If a user copies a paragraph, pastes it at a new location, and then deletes the original — these three operations (copy, paste, delete) are performed as a single logical action. If the system receives the paste and delete operations before the original paragraph is deleted by another concurrent operation, the user may see unexpected behavior. Our system handles this by grouping related operations into "transaction" boundaries within the CRDT, ensuring that compound operations are applied atomically.

5. High-Level Architecture Overview

The collaborative editing system follows a layered architecture with clear separation of concerns. At the highest level, the system consists of five major subsystems: the Client Layer (browser and mobile applications), the API Gateway (authentication, rate limiting, routing), the Collaboration Engine (CRDT synchronization, operation routing), the Persistence Layer (document storage, versioning, search), and the Presence Service (cursors, selections, online status).

graph TB subgraph Client Layer A[Web Client] --> E[WebSocket Client] B[Mobile Client] --> E C[Desktop Client] --> E D[API Client] --> F[REST/GraphQL API] end subgraph API Gateway E --> G[WebSocket Gateway] F --> H[HTTP Gateway] G --> I[Auth Middleware] H --> I I --> J[Rate Limiter] J --> K[Request Router] end subgraph Collaboration Engine K --> L[Document Service] K --> M[CRDT Sync Service] K --> N[Presence Service] M --> O[Operation Queue] O --> P[Conflict Resolution] P --> Q[State Broadcast] end subgraph Persistence Layer L --> R[Document Store] R --> S[Version History] R --> T[Block Storage] R --> U[Search Index] L --> V[File Storage] end subgraph Presence Service N --> W[Cursor Tracker] N --> X[Selection Tracker] N --> Y[Online Status] W --> Z[Presence Broadcast] end

Component Responsibilities

ComponentResponsibilityTechnology
WebSocket GatewayManages persistent connections, heartbeats, reconnection.NET SignalR / Custom WebSocket server
CRDT Sync ServiceReceives client operations, merges with server state, broadcastsCustom CRDT engine (Yjs-compatible)
Presence ServiceTracks cursors, selections, online status for each documentRedis Pub/Sub + in-memory state
Document ServiceCRUD operations on documents, permission checks, metadataASP.NET Core + PostgreSQL
Version HistoryStores document snapshots and operation logsPostgreSQL + S3-compatible object storage
Block StorageStores individual blocks of block-based documentsPostgreSQL JSONB + Redis cache
Search IndexFull-text search across documentsElasticsearch / Meilisearch
File StorageStores embedded images, videos, and other mediaS3-compatible object storage + CDN

Data Flow for a Single Keystroke

To make the architecture concrete, let us trace the complete data flow when User A types a character in a shared document:

  1. User A presses a key in the browser. The editor captures the keystroke and generates a CRDT operation (insert character 'x' at position 42).
  2. The client sends the operation to the WebSocket Gateway via the existing WebSocket connection. The operation includes the client's last known server state vector for optimistic concurrency control.
  3. The WebSocket Gateway forwards the operation to the CRDT Sync Service, which acquires a per-document lock (using a distributed lock in Redis).
  4. The CRDT Sync Service merges the incoming operation with the authoritative server-side CRDT state. Because CRDTs are commutative and associative, this merge is always deterministic and conflict-free.
  5. The server persists the operation to the operation log (append-only in PostgreSQL) and updates the document's current state snapshot (periodically, not on every keystroke).
  6. The CRDT Sync Service broadcasts the merged operation (now including the server-assigned logical timestamp) to all other connected clients via the WebSocket Gateway.
  7. Each receiving client applies the operation to their local CRDT state and re-renders the editor. The character 'x' appears on their screens.
  8. The Presence Service updates User A's cursor position and broadcasts the updated cursor position to all other clients.
Latency Budget: The entire flow from keystroke to remote display must complete in under 100ms. The breakdown: client-side operation generation (1ms), WebSocket send (5-20ms depending on network), server processing and merge (2-5ms), broadcast to other clients (5-20ms), client-side apply and render (5-15ms). Total: 18-61ms. This leaves substantial margin for network jitter and server load.

6. Real-Time Collaboration via WebSocket

WebSocket is the transport layer that makes real-time collaboration possible. Unlike HTTP request-response, WebSocket provides a persistent, full-duplex communication channel between client and server, enabling the server to push operations to clients with minimal latency. Our WebSocket implementation must handle connection management, message framing, backpressure, reconnection, and message ordering guarantees.

Connection Lifecycle

When a user opens a document, the client establishes a WebSocket connection and sends a "join document" message with the document ID and the client's current state vector. The server responds with the current server state and any operations the client has missed (if reconnecting). From this point, the connection is used bidirectionally: the client sends operations, and the server broadcasts merged operations and presence updates.

Heartbeats are sent every 30 seconds to detect dead connections. If the server does not receive a heartbeat within 90 seconds, it considers the client disconnected and removes them from the document's active user list. The client sends heartbeats independently and detects server disconnection after 60 seconds without a server heartbeat. Upon disconnection, the client enters offline mode and queues operations locally.

C#
public class CollaborationWebSocketHandler
{
    private readonly IConnectionManager _connectionManager;
    private readonly ICRDTSyncService _crdtSync;
    private readonly IPresenceService _presence;
    private readonly ILogger<CollaborationWebSocketHandler> _logger;

    public async Task HandleConnectionAsync(WebSocket socket, string documentId, string userId)
    {
        var connection = new DocumentConnection(socket, documentId, userId);
        await _connectionManager.AddConnectionAsync(connection);

        try
        {
            var stateVector = await ReceiveStateVectorAsync(socket);
            var catchUpOps = await _crdtSync.GetOperationsSinceAsync(documentId, stateVector);

            if (catchUpOps.Any())
            {
                await SendOperationsAsync(socket, catchUpOps);
            }

            await _presence.AddUserAsync(documentId, userId);

            var buffer = new byte[64 * 1024];
            while (socket.State == WebSocketState.Open)
            {
                var result = await socket.ReceiveAsync(
                    new ArraySegment<byte>(buffer), CancellationToken.None);

                if (result.MessageType == WebSocketMessageType.Binary)
                {
                    var operation = CRDTOperation.Deserialize(
                        buffer.AsSpan(0, result.Count));

                    var mergedOp = await _crdtSync.MergeOperationAsync(
                        documentId, operation);

                    await BroadcastToDocumentAsync(
                        documentId, userId, mergedOp);

                    await PersistOperationAsync(documentId, mergedOp);
                }
                else if (result.MessageType == WebSocketMessageType.Close)
                {
                    break;
                }
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "WebSocket error for user {UserId} on document {DocId}",
                userId, documentId);
        }
        finally
        {
            await _connectionManager.RemoveConnectionAsync(connection);
            await _presence.RemoveUserAsync(documentId, userId);
        }
    }

    private async Task BroadcastToDocumentAsync(
        string documentId, string excludeUserId, CRDTOperation op)
    {
        var connections = _connectionManager
            .GetConnections(documentId)
            .Where(c => c.UserId != excludeUserId);

        var payload = op.Serialize();
        var tasks = connections.Select(async conn =>
        {
            try
            {
                await conn.Socket.SendAsync(
                    new ArraySegment<byte>(payload),
                    WebSocketMessageType.Binary,
                    true,
                    CancellationToken.None);
            }
            catch (WebSocketException)
            {
                // Connection is dead; cleanup will handle removal
            }
        });

        await Task.WhenAll(tasks);
    }
}

Message Protocol

All messages between client and server use a compact binary protocol based on Protocol Buffers. Each message includes a type discriminator, a sequence number for ordering, and a payload specific to the message type. The message types include: Operation (CRDT edit), PresenceUpdate (cursor/selection), StateVector (for sync negotiation), CatchUpResponse (missed operations), Ack (server acknowledgement of client operation), and Heartbeat.

Binary encoding reduces bandwidth by 60-80% compared to JSON encoding, which is critical for mobile clients on cellular networks. A typical character insertion operation is encoded as 12-20 bytes in our binary format, compared to 80-120 bytes in JSON. For a system processing billions of operations per day, this difference translates to significant infrastructure cost savings.

Backpressure and Flow Control

When a client falls behind (due to a slow device or network), the server must apply backpressure to prevent unbounded memory growth. Our system uses a credit-based flow control mechanism: the server grants each client a "send credit" that determines how many operations the client can send before waiting for acknowledgement. If a client's buffer grows beyond a threshold (e.g., 10,000 pending operations), the server temporarily suspends the client's send permission and sends a "slow down" message. The client responds by batching operations more aggressively and reducing the frequency of sends.

7. Cursor Presence & Selection Synchronization

Cursor presence — showing where other users are looking and selecting in the document — is what transforms collaborative editing from "seeing each other's changes" to "feeling like you're in the same room." Without presence, users cannot anticipate each other's actions, leading to conflicting edits and a disconnected experience. With presence, users can coordinate naturally: "I'll edit the introduction while you work on the conclusion" becomes visible and self-enforcing.

Presence Data Model

Each user's presence in a document is represented by three pieces of information: their cursor position (a single point in the document), their selection range (an optional start and end position indicating highlighted text), and their viewport (the visible portion of the document, useful for scrolling awareness). These values are expressed as CRDT positions — stable identifiers that remain valid even as the document changes — rather than raw character offsets that would shift with every edit.

C#
public class UserPresence
{
    public string UserId { get; set; }
    public string DisplayName { get; set; }
    public string Color { get; set; }
    public CrdtPosition CursorPosition { get; set; }
    public CrdtRange? SelectionRange { get; set; }
    public CrdtRange? ViewportRange { get; set; }
    public DateTime LastUpdate { get; set; }
    public PresenceStatus Status { get; set; }
}

public class PresenceService
{
    private readonly IDistributedCache _cache;
    private readonly ISubscriber _redisPubSub;

    public async Task UpdateCursorAsync(
        string documentId, string userId, CrdtPosition position)
    {
        var presence = new UserPresence
        {
            UserId = userId,
            CursorPosition = position,
            LastUpdate = DateTime.UtcNow,
            Status = PresenceStatus.Active
        };

        var key = $"presence:{documentId}:{userId}";
        await _cache.SetAsync(key, presence, new DistributedCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
        });

        await _redisPubSub.PublishAsync(
            $"presence:{documentId}",
            PresenceUpdate.Serialize(presence));
    }

    public async Task<List<UserPresence>> GetAllPresenceAsync(string documentId)
    {
        var pattern = $"presence:{documentId}:*";
        var entries = await _cache.SearchKeysAsync(pattern);

        return entries
            .Select(e => JsonSerializer.Deserialize<UserPresence>(e))
            .Where(p => p.LastUpdate.AddSeconds(30) > DateTime.UtcNow)
            .ToList();
    }
}

Presence Broadcasting

Presence updates are broadcast via a separate channel from document operations to avoid contaminating the CRDT state with ephemeral presence data. Our system uses Redis Pub/Sub for presence broadcasting: each document has a Redis channel, and presence updates are published to this channel. All servers subscribed to the channel receive the update and forward it to their connected WebSocket clients. This approach scales well because presence updates are fire-and-forget — they do not need to be persisted or ordered.

Presence updates are throttled on the client side: cursor movement events are batched and sent no more than 10 times per second (every 100ms). Selection changes are sent immediately (they are infrequent and meaningful). Viewport changes (scrolling) are sent every 200ms. This throttle reduces the presence bandwidth to approximately 1KB per user per second, which is manageable even with 50 concurrent users on a document.

Cursor Rendering

Rendering remote cursors requires mapping CRDT positions to screen coordinates. The client maintains a "position map" that tracks the visual position of each CRDT position in the document. When a remote cursor's CRDT position changes (due to the user typing or the document changing around them), the client looks up the visual position and smoothly animates the cursor to its new location. This animation is critical for a polished experience — instant cursor jumps feel jarring, while smooth 150ms CSS transitions feel natural.

UX Detail: Each user is assigned a unique color from a predefined palette of 24 distinct colors. The color is derived from a hash of the user's ID, ensuring consistency across sessions. Users who are actively typing have a slightly thicker cursor line and a small name label above the cursor. Users who are idle (no activity for 30 seconds) have their cursor fade to 50% opacity. Users who are selecting text have their selection highlighted in a translucent version of their cursor color.

8. Document Versioning & History

Document versioning serves multiple purposes: it enables users to undo mistakes, provides an audit trail for compliance, supports "time travel" debugging of document state, and enables branching (creating a copy of the document at a specific point in time). Our versioning system combines two complementary approaches: an operation log (storing every operation ever applied) and periodic snapshots (storing the complete document state at regular intervals).

Operation Log

The operation log is an append-only table in PostgreSQL that stores every CRDT operation applied to the document. Each entry includes: a monotonically increasing sequence number, the user who performed the operation, the operation payload (serialized CRDT operation), the server timestamp, and the logical clock value. The operation log is the source of truth for the document's history — the current document state can be reconstructed by replaying all operations from the beginning.

C#
public class DocumentVersion
{
    public long SequenceNumber { get; set; }
    public string DocumentId { get; set; }
    public string UserId { get; set; }
    public byte[] OperationPayload { get; set; }
    public DateTime ServerTimestamp { get; set; }
    public long LogicalClock { get; set; }
    public string OperationType { get; set; }
}

public class VersionHistoryService
{
    private readonly IDbConnection _db;
    private readonly ISnapshotStore _snapshots;

    public async Task<DocumentState> GetStateAtVersionAsync(
        string documentId, long targetSequence)
    {
        var snapshot = await _snapshots.GetLatestSnapshotBeforeAsync(
            documentId, targetSequence);

        var startSeq = snapshot?.SequenceNumber ?? 0;
        var operations = await _db.QueryAsync<DocumentVersion>(
            "SELECT * FROM document_versions " +
            "WHERE document_id = @DocId AND sequence_number > @Start " +
            "AND sequence_number <= @Target " +
            "ORDER BY sequence_number",
            new { DocId = documentId, Start = startSeq, Target = targetSequence });

        var state = snapshot?.State ?? CRDTDocument.Empty;

        foreach (var op in operations)
        {
            var crdtOp = CRDTOperation.Deserialize(op.OperationPayload);
            state = state.Apply(crdtOp);
        }

        return state;
    }

    public async Task<List<DocumentVersion>> GetHistoryAsync(
        string documentId, int limit = 100, long beforeSequence = long.MaxValue)
    {
        return (await _db.QueryAsync<DocumentVersion>(
            "SELECT * FROM document_versions " +
            "WHERE document_id = @DocId AND sequence_number < @Before " +
            "ORDER BY sequence_number DESC LIMIT @Limit",
            new { DocId = documentId, Before = beforeSequence, Limit = limit }))
            .ToList();
    }

    public async Task CreateSnapshotAsync(string documentId, long sequenceNumber)
    {
        var state = await GetStateAtVersionAsync(documentId, sequenceNumber);
        await _snapshots.SaveSnapshotAsync(documentId, sequenceNumber, state);
    }
}

Snapshotting Strategy

Replaying the operation log from the beginning is expensive for large, long-lived documents. Snapshots provide an optimization: by periodically saving the complete document state, we can reconstruct any version by replaying only the operations since the most recent snapshot. Our snapshotting strategy triggers a snapshot every 1,000 operations or every 24 hours, whichever comes first. Snapshots are stored in compressed format in S3-compatible object storage, with the most recent snapshot cached in memory for fast access.

Version Comparison (Diff)

Users can compare any two versions of a document to see what changed. Because we store operations rather than just snapshots, we can produce a semantic diff: not just "these characters changed" but "User A inserted 'hello' at position 42, User B deleted the paragraph at position 100, and User C changed the font of the heading at position 0." This operation-level diff is far more informative than a character-level diff for collaborative documents.

sequenceDiagram participant Client participant Server participant Store Client->>Server: Get version history (last 20 changes) Server->>Store: Query operation log (DESC by sequence) Store-->>Server: List of operations with user info Server-->>Client: Version history response Client->>Server: Get document state at version 5000 Server->>Store: Get latest snapshot before 5000 Store-->>Server: Snapshot at version 4500 Server->>Store: Get operations 4501-5000 Store-->>Server: 500 operations Server->>Server: Replay operations on snapshot Server-->>Client: Complete document state

9. Undo/Redo with Multi-User Awareness

Undo/redo in a collaborative editing system is deceptively complex. In a single-user editor, undo simply reverts the last operation. In a collaborative editor, undo must: (1) only undo the current user's changes, not other users' changes, (2) handle cases where other users have edited the regions affected by the undone operation, (3) maintain cursor position consistency after undo, and (4) prevent undo from causing document corruption or convergence issues.

The Inversion Problem

The fundamental challenge is operation inversion. When User A inserts "hello" at position 5, undo requires deleting "hello" from position 5. But if User B has since inserted text before position 5, the position has shifted. The undo operation must be transformed against all intervening operations to find the correct position. In OT systems, this is handled by composing the undo operation with inverse operations and transforming against concurrent operations. In CRDT systems, the approach is different: we store "undo stacks" as additional CRDT operations that invert the original operations.

C#
public class CollaborativeUndoManager
{
    private readonly ICRDTDocument _document;
    private readonly Stack<UndoEntry> _undoStack;
    private readonly Stack<UndoEntry> _redoStack;

    public void TrackOperation(CRDTOperation op, string userId)
    {
        if (userId == _document.CurrentUserId)
        {
            var inverse = op.Invert();
            _undoStack.Push(new UndoEntry
            {
                Operation = op,
                Inverse = inverse,
                Timestamp = DateTime.UtcNow
            });
            _redoStack.Clear();
        }
    }

    public CRDTOperation? Undo()
    {
        if (_undoStack.Count == 0) return null;

        var entry = _undoStack.Pop();

        var undoOp = entry.Inverse.ResolveAgainstDocument(_document);
        if (undoOp == null)
        {
            return Undo();
        }

        _redoStack.Push(new UndoEntry
        {
            Operation = undoOp,
            Inverse = undoOp.Invert(),
            Timestamp = DateTime.UtcNow
        });

        return undoOp;
    }

    public CRDTOperation? Redo()
    {
        if (_redoStack.Count == 0) return null;

        var entry = _redoStack.Pop();

        var redoOp = entry.Inverse.ResolveAgainstDocument(_document);
        if (redoOp == null)
        {
            return Redo();
        }

        _undoStack.Push(new UndoEntry
        {
            Operation = redoOp,
            Inverse = redoOp.Invert(),
            Timestamp = DateTime.UtcNow
        });

        return redoOp;
    }
}

public class UndoEntry
{
    public CRDTOperation Operation { get; set; }
    public CRDTOperation Inverse { get; set; }
    public DateTime Timestamp { get; set; }
}

Undo Stack Isolation

Each user maintains their own undo stack, and undo/redo operations are scoped to the current user's changes only. When User A undoes their last edit, only User A's cursor moves back, and only User A's change is reverted. Other users' changes in the same region are preserved. This is achieved by tagging each operation with the user who created it and filtering the undo stack to include only the current user's operations.

Gotcha: Undo in collaborative editing is not "revert to a previous state" — it is "apply the inverse of my last operation, transformed against all operations that have occurred since." This distinction is critical. If User A typed "hello" and then User B edited nearby, undoing User A's typing does not revert User B's changes. The undo operation is surgically precise, removing only the specific change User A made, adjusted for any document modifications that happened in the meantime.

History Splitting

Our system uses a technique called "history splitting" to manage undo stacks efficiently. Rather than storing individual character-level operations, we group operations into logical "chunks" (e.g., a word, a paste operation, a formatting change). When the user presses Ctrl+Z, the entire chunk is undone as a unit. This provides a more intuitive undo experience: pressing Ctrl+Z removes the last word, not the last character. Chunks are separated by time gaps (more than 1 second between operations) or by operation type changes (switching from typing to pasting).

10. Offline Editing & Sync

Offline editing is a defining feature of modern collaborative editors. Users expect to work on documents during flights, in areas with poor connectivity, and on mobile devices that frequently lose network access. The system must ensure that offline edits are never lost and that reconnection produces a consistent state without data loss or corruption.

Offline Mode Operation

When the client detects a network disconnection, it enters offline mode. In offline mode, the client continues to accept user edits and applies them to a local copy of the CRDT state. Each edit is assigned a client-local timestamp and stored in an "outbox" — a queue of operations that have not yet been acknowledged by the server. The local CRDT state diverges from the server state as offline edits accumulate, but the CRDT guarantees that this divergence will be cleanly resolved upon reconnection.

C#
public class OfflineSyncManager
{
    private readonly ICRDTDocument _localDocument;
    private readonly IOperationStore _outbox;
    private readonly ISyncProtocol _syncProtocol;
    private readonly ILogger<OfflineSyncManager> _logger;

    public bool IsOnline { get; private set; }

    public CRDTOperation ApplyLocalEdit(CRDTOperation op)
    {
        _localDocument.Apply(op);
        _outbox.Enqueue(op);

        if (IsOnline)
        {
            _ = SendToServerAsync(op);
        }

        return op;
    }

    public async Task ReconnectAsync()
    {
        IsOnline = true;

        try
        {
            var serverState = await _syncProtocol
                .GetServerStateAsync(_localDocument.Id);

            var unsyncedOps = await _outbox.GetAllAsync();

            var syncResult = await _syncProtocol.SyncAsync(
                _localDocument.Id,
                _localDocument.GetStateVector(),
                unsyncedOps);

            foreach (var remoteOp in syncResult.MissingOperations)
            {
                _localDocument.Apply(remoteOp);
            }

            foreach (var confirmedOp in syncResult.ConfirmedOperations)
            {
                await _outbox.AcknowledgeAsync(confirmedOp.Id);
            }

            foreach (var rejectedOp in syncResult.RejectedOperations)
            {
                await HandleRejectedOperationAsync(rejectedOp);
            }

            _logger.LogInformation(
                "Sync complete: {Confirmed} confirmed, {Rejected} rejected, {Received} received",
                syncResult.ConfirmedOperations.Count,
                syncResult.RejectedOperations.Count,
                syncResult.MissingOperations.Count);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Sync failed; will retry");
            IsOnline = false;
        }
    }

    private async Task HandleRejectedOperationAsync(RejectedOperation op)
    {
        var resolved = op.SuggestedResolution.Resolve(_localDocument);
        _localDocument.Apply(resolved);
        await _outbox.ReplaceAsync(op.Id, resolved);
    }
}

Sync Protocol

Upon reconnection, the client and server perform a state vector exchange to determine exactly which operations the client is missing and which of the client's offline operations the server has not yet seen. The client sends its state vector (a summary of all operations it has applied), and the server responds with all operations that are not included in the client's state vector. The client then sends any operations in its outbox that the server has not seen. Both sides apply the received operations and acknowledge them.

Conflict-Free Reconnection

Because we use CRDTs, the reconnection process is inherently conflict-free. The server does not need to decide between competing edits — it simply merges all operations using the CRDT merge function. The document state after sync is identical on both client and server, regardless of the order in which offline operations are applied. This is one of the strongest arguments for CRDTs over OT in systems that support offline editing: OT typically requires a central server to resolve conflicts during reconnection, while CRDTs resolve conflicts locally.

Data Durability Guarantee: Every operation that the user performs, whether online or offline, is guaranteed to be preserved. Offline operations are stored in an encrypted local SQLite database (using SQLCipher) and in the server's operation log after sync. Even if the client device is lost or destroyed, operations that have been synced to the server are safe. Operations that have not been synced are lost with the device, but this is an inherent limitation of any client-server system — the client is the only witness to edits made while offline.

11. Document Storage — Block-Based Architecture

Modern collaborative editors like Notion, Coda, and BlockSuite use a block-based document model rather than a flat text buffer. In a block-based model, the document is a tree of typed blocks: paragraph blocks, heading blocks, list blocks, image blocks, table blocks, code blocks, and so on. Each block has its own content, formatting, and metadata. This architecture provides natural boundaries for permissions (block-level access control), conflict resolution (block-level LWW), and rendering (each block type has its own renderer).

Block Data Model

C#
public abstract class DocumentBlock
{
    public string BlockId { get; set; }
    public string DocumentId { get; set; }
    public string ParentBlockId { get; set; }
    public string BlockType { get; set; }
    public int SortOrder { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime UpdatedAt { get; set; }
    public string CreatedBy { get; set; }
    public Dictionary<string, object> Metadata { get; set; }
}

public class ParagraphBlock : DocumentBlock
{
    public List<TextSegment> Content { get; set; }
}

public class HeadingBlock : DocumentBlock
{
    public int Level { get; set; }
    public List<TextSegment> Content { get; set; }
}

public class ImageBlock : DocumentBlock
{
    public string FileUrl { get; set; }
    public string AltText { get; set; }
    public int Width { get; set; }
    public int Height { get; set; }
    public string Caption { get; set; }
}

public class CodeBlock : DocumentBlock
{
    public string Language { get; set; }
    public string Code { get; set; }
    public bool ShowLineNumbers { get; set; }
}

public class TextSegment
{
    public string Text { get; set; }
    public TextFormatting Formatting { get; set; }
    public string? Link { get; set; }
    public string? MentionUserId { get; set; }
}

public class TextFormatting
{
    public bool Bold { get; set; }
    public bool Italic { get; set; }
    public bool Underline { get; set; }
    public bool Strikethrough { get; set; }
    public string? Code { get; set; }
    public string? Color { get; set; }
    public string? Highlight { get; set; }
}

Storage Strategy

Blocks are stored in PostgreSQL using JSONB columns for the flexible content fields. This provides efficient querying (using GIN indexes on JSONB) while maintaining relational integrity through foreign keys on BlockId and ParentBlockId. The block tree structure is maintained through the ParentBlockId and SortOrder columns, enabling efficient tree traversal for rendering and block-level operations.

For the CRDT layer, each block is treated as an independent CRDT entity. Inserting, deleting, or reordering blocks produces block-level CRDT operations. The content within each block (text, formatting) produces character-level CRDT operations nested within the block. This two-level CRDT model — block-level for structure, character-level for content — provides efficient synchronization for both large structural changes and small text edits.

Block TypeContent ModelCRDT GranularityTypical Size
ParagraphList of text segmentsCharacter-level100-500 bytes
HeadingList of text segments + levelCharacter-level50-200 bytes
Bullet ListList of list itemsItem-level100-1000 bytes
ImageFile reference + metadataBlock-level LWW200 bytes (metadata)
Table2D grid of cellsCell-level500-5000 bytes
Code BlockLanguage + code stringCharacter-level100-10000 bytes
EmbedURL + provider metadataBlock-level LWW300 bytes

12. Granular Permissions & Access Control

A collaborative editing system must enforce access controls at multiple levels: workspace-level (who can access the workspace), document-level (who can view, comment, or edit a document), block-level (who can edit specific sections), and operation-level (what operations are allowed). This hierarchical permission model enables scenarios like "external contractors can edit only the introduction section" or "viewers can read the document but only owners can manage settings."

Permission Model

C#
public enum PermissionLevel
{
    None = 0,
    View = 1,
    Comment = 2,
    Edit = 3,
    Admin = 4,
    Owner = 5
}

public class DocumentPermission
{
    public string DocumentId { get; set; }
    public string PrincipalId { get; set; }
    public PrincipalType PrincipalType { get; set; }
    public PermissionLevel Level { get; set; }
    public string? Scope { get; set; }
    public DateTime GrantedAt { get; set; }
    public string? GrantedBy { get; set; }
    public DateTime? ExpiresAt { get; set; }
}

public class PermissionService
{
    private readonly IDbConnection _db;
    private readonly IPermissionCache _cache;

    public async Task<PermissionLevel> GetEffectivePermissionAsync(
        string documentId, string userId, string? blockId = null)
    {
        var cacheKey = $"{documentId}:{userId}:{blockId ?? "doc"}";
        var cached = await _cache.GetAsync(cacheKey);
        if (cached != null) return cached;

        var userPermission = await _db.QueryFirstOrDefaultAsync<DocumentPermission>(
            "SELECT * FROM document_permissions " +
            "WHERE document_id = @DocId AND principal_id = @UserId " +
            "AND (expires_at IS NULL OR expires_at > NOW()) " +
            "ORDER BY level DESC LIMIT 1",
            new { DocId = documentId, UserId = userId });

        if (userPermission == null)
        {
            var workspacePerm = await GetWorkspacePermissionAsync(
                documentId, userId);
            if (workspacePerm == null) return PermissionLevel.None;
            return await ResolveBlockPermissionAsync(
                documentId, userId, blockId, workspacePerm);
        }

        return await ResolveBlockPermissionAsync(
            documentId, userId, blockId, userPermission.Level);
    }

    public async Task<bool> AuthorizeOperationAsync(
        string documentId, string userId, CRDTOperation op)
    {
        var permission = await GetEffectivePermissionAsync(
            documentId, userId);

        return op switch
        {
            InsertOperation _ => permission >= PermissionLevel.Edit,
            DeleteOperation _ => permission >= PermissionLevel.Edit,
            FormatOperation _ => permission >= PermissionLevel.Edit,
            CommentOperation _ => permission >= PermissionLevel.Comment,
            ViewOperation _ => permission >= PermissionLevel.View,
            _ => false
        };
    }
}

Permission Inheritance

Permissions follow an inheritance model: workspace permissions are inherited by all documents in the workspace, document permissions are inherited by all blocks in the document, and block-level permissions can override (restrict) inherited permissions but never grant more access than the document-level permission. This ensures that granting "edit access to the introduction section" does not inadvertently grant "edit access to the entire document."

Security Note: Permission checks must be enforced on the server side for every operation, not just on the client side. A malicious client could attempt to send operations for blocks it does not have permission to edit. The server must validate the permission before applying the operation and reject unauthorized operations with an appropriate error message. Client-side permission checks are a UX optimization (hiding buttons the user cannot use) but are not a security mechanism.

13. Commenting System & Annotations

Comments and annotations transform a document from a static text artifact into a living conversation. The commenting system must support threaded discussions, mentions, reactions, resolution tracking, and rich text within comments. Comments are anchored to specific text ranges in the document, and as the document changes (text is inserted, deleted, or moved), comments must maintain their anchor points.

Comment Data Model

C#
public class Comment
{
    public string CommentId { get; set; }
    public string DocumentId { get; set; }
    public string? ParentCommentId { get; set; }
    public string AuthorId { get; set; }
    public string Content { get; set; }
    public List<Mention> Mentions { get; set; }
    public AnchorRange Anchor { get; set; }
    public CommentStatus Status { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ResolvedAt { get; set; }
    public string? ResolvedBy { get; set; }
    public List<Reaction> Reactions { get; set; }
}

public class AnchorRange
{
    public string StartBlockId { get; set; }
    public int StartOffset { get; set; }
    public string EndBlockId { get; set; }
    public int EndOffset { get; set; }
    public string AnchorText { get; set; }
}

public class Mention
{
    public string UserId { get; set; }
    public int Offset { get; set; }
}

public enum CommentStatus
{
    Open,
    Resolved,
    Deleted
}

Anchor Stability

The most challenging aspect of the commenting system is anchor stability. When text is inserted or deleted within a commented range, the comment's anchor positions must shift accordingly. When text outside a commented range is modified, the anchors should remain stable. Our system uses CRDT positions (same as cursor positions) for comment anchors, which automatically shift correctly as the document evolves. When the entire anchor text is deleted, the comment enters a "detached" state and is shown in the comment sidebar with a "this comment's context was deleted" indicator.

Comments are stored as independent CRDT entities (separate from the document content CRDT). This means comments can be added, resolved, and deleted without affecting the document content, and changes to comments do not appear in the document's undo/redo stack. The comment CRDT is synchronized through the same WebSocket channel as the document content, ensuring that comments and document changes are consistently ordered.

Notification System

When a user is @mentioned in a comment, the system sends a notification via multiple channels: in-app notification (stored in a notifications table and displayed in the notification bell), email notification (for offline users), and push notification (for mobile users). Notifications are batched: if a user receives multiple mentions within a 5-minute window, they are consolidated into a single notification digest to avoid notification fatigue.

14. Change History & Audit Trail

An audit trail provides a complete, tamper-evident record of every action performed on a document. While the operation log (Section 8) captures document content changes, the audit trail captures the full spectrum of user actions: document creation, permission changes, sharing events, comment activity, file uploads, settings modifications, and content changes. The audit trail is essential for compliance (SOC 2, HIPAA, GDPR), security (detecting unauthorized access), and governance (understanding who did what and when).

Audit Event Model

C#
public class AuditEvent
{
    public string EventId { get; set; }
    public string DocumentId { get; set; }
    public string UserId { get; set; }
    public string ActionType { get; set; }
    public Dictionary<string, object> Details { get; set; }
    public DateTime Timestamp { get; set; }
    public string? IpAddress { get; set; }
    public string? UserAgent { get; set; }
    public string? CorrelationId { get; set; }
}

public enum AuditActionType
{
    DocumentCreated,
    DocumentDeleted,
    DocumentRenamed,
    PermissionChanged,
    DocumentShared,
    DocumentExported,
    CommentAdded,
    CommentResolved,
    BlockInserted,
    BlockDeleted,
    BlockModified,
    MediaUploaded,
    SettingsChanged,
    VersionRestored,
    CollaborationStarted,
    CollaborationEnded
}

public class AuditService
{
    private readonly IDbConnection _db;
    private readonly IAuditLogPublisher _publisher;

    public async Task LogEventAsync(AuditEvent auditEvent)
    {
        auditEvent.EventId = Guid.NewGuid().ToString();
        auditEvent.Timestamp = DateTime.UtcNow;

        await _db.ExecuteAsync(
            "INSERT INTO audit_events " +
            "(event_id, document_id, user_id, action_type, details, " +
            " timestamp, ip_address, user_agent, correlation_id) " +
            "VALUES (@EventId, @DocumentId, @UserId, @ActionType, @Details::jsonb, " +
            " @Timestamp, @IpAddress, @UserAgent, @CorrelationId)",
            auditEvent);

        await _publisher.PublishAsync(auditEvent);
    }

    public async Task<List<AuditEvent>> GetAuditTrailAsync(
        string documentId, DateTime? from = null, DateTime? to = null,
        int limit = 500)
    {
        return (await _db.QueryAsync<AuditEvent>(
            "SELECT * FROM audit_events " +
            "WHERE document_id = @DocId " +
            "AND (@From IS NULL OR timestamp >= @From) " +
            "AND (@To IS NULL OR timestamp <= @To) " +
            "ORDER BY timestamp DESC " +
            "LIMIT @Limit",
            new { DocId = documentId, From = from, To = to, Limit = limit }))
            .ToList();
    }
}

Audit Trail Storage

Audit events are stored in a dedicated PostgreSQL table with a time-based partitioning strategy: a new partition is created for each month, and partitions older than 12 months are archived to cold storage (S3-compatible object storage). This partitioning strategy enables efficient querying for recent events (hot path) while keeping storage costs manageable for historical data (cold path). Audit events include a cryptographic hash chain: each event includes the hash of the previous event, creating a tamper-evident chain that makes it detectable if an event is modified or deleted after the fact.

15. Performance Optimization for Large Documents

Performance is a first-class concern in collaborative editing. Large documents (100,000+ characters), documents with many concurrent editors (50+), and documents with complex formatting (tables, nested lists, code blocks) all stress different parts of the system. Our optimization strategy addresses three dimensions: client-side rendering performance, server-side CRDT merge performance, and network efficiency.

Client-Side Virtualization

Rendering a 100,000-character document in the browser is expensive: DOM manipulation, layout calculation, and style computation can take hundreds of milliseconds, causing visible jank. Our solution is viewport-based virtualization: only the blocks visible in the viewport (plus a buffer of 5 blocks above and below) are rendered in the DOM. As the user scrolls, blocks are dynamically added to and removed from the DOM. This keeps the DOM size constant (approximately 20-30 blocks) regardless of document length, enabling smooth 60fps scrolling even in very large documents.

CRDT Encoding Optimization

CRDT position identifiers can grow very long in large documents (the LSEQ algorithm, for example, produces identifiers of logarithmic length). For a 100,000-character document, each position identifier might be 20-30 bytes. Transmitting these identifiers with every operation adds significant bandwidth overhead. Our optimization is a compact encoding scheme that represents position identifiers as relative offsets from the nearest known position, reducing the average identifier size to 4-8 bytes. We also use variable-length encoding (similar to Protocol Buffers' varint) to represent small numbers compactly.

C#
public class CompactCRDTCodec
{
    private readonly Dictionary<CrdtPosition, int> _positionCache;

    public byte[] EncodeOperation(CRDTOperation op)
    {
        using var ms = new MemoryStream();
        using var writer = new BinaryWriter(ms);

        writer.Write((byte)op.Type);

        switch (op)
        {
            case InsertOperation insert:
                var relativePos = EncodePositionRelative(
                    insert.Position, insert.ContextPosition);
                writer.Write(relativePos);
                WriteVarint(writer, (uint)insert.Content.Length);
                writer.Write(insert.Content);
                break;

            case DeleteOperation delete:
                var delRelativePos = EncodePositionRelative(
                    delete.Position, delete.ContextPosition);
                writer.Write(delRelativePos);
                WriteVarint(writer, (uint)delete.Length);
                break;

            case FormatOperation format:
                WriteVarint(writer, (uint)format.StartOffset);
                WriteVarint(writer, (uint)format.EndOffset);
                WriteFormatFlags(writer, format.Attributes);
                break;
        }

        return ms.ToArray();
    }

    private byte[] EncodePositionRelative(
        CrdtPosition target, CrdtPosition reference)
    {
        var path = target.GetPath();
        var refPath = reference?.GetPath();

        int commonPrefix = 0;
        if (refPath != null)
        {
            while (commonPrefix < path.Length &&
                   commonPrefix < refPath.Length &&
                   path[commonPrefix] == refPath[commonPrefix])
            {
                commonPrefix++;
            }
        }

        using var ms = new MemoryStream();
        ms.WriteByte((byte)commonPrefix);
        for (int i = commonPrefix; i < path.Length; i++)
        {
            WriteVarint(ms, (uint)path[i]);
        }
        return ms.ToArray();
    }

    private static void WriteVarint(Stream stream, uint value)
    {
        while (value > 0x7F)
        {
            stream.WriteByte((byte)(value | 0x80));
            value >>= 7;
        }
        stream.WriteByte((byte)value);
    }
}

Operation Batching and Debouncing

When a user types quickly, generating a WebSocket message for every keystroke is wasteful. Our client-side editor batches consecutive character insertions into a single operation. If the user types "hello" rapidly, the client generates a single "insert 'hello' at position 42" operation rather than five separate "insert 'h', insert 'e', insert 'l', insert 'l', insert 'o'" operations. The batching window is 50 milliseconds — fast enough to feel real-time but long enough to capture rapid typing. Delete operations are batched similarly: holding backspace generates a single "delete 10 characters at position 42" operation.

Incremental Rendering

When the client receives a batch of remote operations, rendering changes incrementally is more efficient than re-rendering the entire document. Our editor uses a "diff and patch" approach: after applying CRDT operations to the local state, the editor computes the minimal set of DOM changes needed and applies them. This is typically 1-3 DOM operations per CRDT operation, compared to a full re-render that might touch hundreds of DOM nodes. For formatting changes, only the affected text nodes are updated. For structural changes (block insert/delete/reorder), the corresponding DOM elements are moved, added, or removed.

Performance Metrics: Our target metrics are: keystroke-to-render latency under 16ms (one frame at 60fps) for local edits, remote operation apply-to-render under 30ms, document initial load under 2 seconds for documents up to 500KB, and memory usage under 50MB for documents up to 1MB. These targets are achievable with the optimization strategies described above.

16. Media Embedding & Rich Content

Rich documents contain more than just text. Images, videos, file attachments, embeds (YouTube, Figma, CodePen), and other media types are essential for modern collaborative documents. Media embedding introduces unique challenges: large binary content cannot be stored inline in the CRDT, media rendering varies across platforms, and concurrent edits to media blocks require careful handling.

Media Upload Pipeline

When a user uploads an image, the system processes it through a multi-stage pipeline: (1) The client uploads the raw file to a pre-signed S3 upload URL. (2) The server generates multiple derivatives: a thumbnail (150x150), a medium preview (800px wide), and a full-size version. For images, derivatives are generated using ImageSharp. For videos, thumbnails are extracted using FFmpeg. (3) The server stores the derivatives in S3 and creates a media block in the document referencing the S3 URLs. (4) The media block is inserted into the document's CRDT state and broadcast to all clients.

C#
public class MediaUploadService
{
    private readonly IBlobStorage _storage;
    private readonly IImageProcessor _imageProcessor;

    public async Task<MediaBlock> ProcessUploadAsync(
        Stream fileStream, string fileName, string contentType,
        string documentId, string userId)
    {
        var mediaId = Guid.NewGuid().ToString();

        var derivatives = new Dictionary<string, string>();

        if (contentType.StartsWith("image/"))
        {
            var original = await _storage.UploadAsync(
                $"{documentId}/{mediaId}/original-{fileName}",
                fileStream, contentType);

            fileStream.Position = 0;
            var thumbnail = await _imageProcessor.ResizeAsync(
                fileStream, 150, 150);
            derivatives["thumbnail"] = await _storage.UploadAsync(
                $"{documentId}/{mediaId}/thumbnail.webp",
                thumbnail, "image/webp");

            fileStream.Position = 0;
            var medium = await _imageProcessor.ResizeAsync(
                fileStream, 800, null);
            derivatives["medium"] = await _storage.UploadAsync(
                $"{documentId}/{mediaId}/medium.webp",
                medium, "image/webp");

            derivatives["original"] = original;
        }
        else
        {
            var url = await _storage.UploadAsync(
                $"{documentId}/{mediaId}/{fileName}",
                fileStream, contentType);
            derivatives["original"] = url;
        }

        return new MediaBlock
        {
            BlockId = mediaId,
            DocumentId = documentId,
            FileName = fileName,
            ContentType = contentType,
            Urls = derivatives,
            UploadedBy = userId,
            UploadedAt = DateTime.UtcNow
        };
    }
}

Embed Resolution

For external embeds (YouTube videos, Figma designs, CodePen snippets), the system uses an oEmbed-like protocol. When a user pastes a URL, the client sends the URL to the server, which resolves the embed metadata (title, thumbnail, dimensions) using the provider's oEmbed endpoint. The resolved metadata is cached for 24 hours and stored in the embed block. Other clients render the embed using an iframe or custom renderer, without needing to resolve the URL themselves.

17. Plugin System & Extensibility

A plugin system transforms a collaborative editor from a fixed-function tool into a platform. Plugins can add custom block types, slash commands, toolbar buttons, keyboard shortcuts, sidebar panels, and integrations with external services. The plugin system must be sandboxed (plugins cannot access other plugins' data or break the editor), performant (plugins should not block the main thread), and collaborative (plugins' state changes should be synchronized across users via the CRDT).

Plugin Architecture

C#
public interface IEditorPlugin
{
    string PluginId { get; }
    string PluginName { get; }
    Version Version { get; }

    Task InitializeAsync(IPluginContext context);
    Task<PluginManifest> GetManifestAsync();
}

public interface IPluginContext
{
    IDocumentAccess Document { get; }
    IUserAccess User { get; }
    IUIAccess UI { get; }
    IEventBus Events { get; }
    IStorageAccess Storage { get; }
}

public class PluginSandbox
{
    private readonly Dictionary<string, IEditorPlugin> _plugins;
    private readonly IPermissionChecker _permissions;

    public async Task<object?> ExecutePluginMethodAsync(
        string pluginId, string methodName, object[] args)
    {
        var plugin = _plugins.GetValueOrDefault(pluginId);
        if (plugin == null)
            throw new PluginNotFoundException(pluginId);

        var requiredPermissions = GetRequiredPermissions(methodName);
        if (!await _permissions.HasPermissionsAsync(pluginId, requiredPermissions))
            throw new PluginPermissionDeniedException(pluginId, methodName);

        try
        {
            var method = plugin.GetType().GetMethod(methodName);
            return await (Task<object?>)method.Invoke(plugin, args);
        }
        catch (Exception ex)
        {
            throw new PluginExecutionException(pluginId, methodName, ex);
        }
    }
}

public class PluginManifest
{
    public string Id { get; set; }
    public string Name { get; set; }
    public List<BlockTypeDefinition> CustomBlocks { get; set; }
    public List<SlashCommandDefinition> SlashCommands { get; set; }
    public List<ToolbarButtonDefinition> ToolbarButtons { get; set; }
    public List<PluginPermission> RequiredPermissions { get; set; }
    public string IconUrl { get; set; }
    public string Description { get; set; }
}

Plugin State Synchronization

Plugins that maintain state (e.g., a kanban board plugin, a drawing plugin) need their state synchronized across collaborators. Our system provides a "plugin CRDT namespace" — a separate CRDT document scoped to the plugin within the parent document. Plugin state changes are synchronized through the same WebSocket channel as the parent document, ensuring ordering and consistency. The plugin CRDT is isolated from the document CRDT, so plugin state changes do not pollute the document's undo/redo stack.

Plugin Marketplace

Plugins are distributed through a marketplace where developers publish plugins and administrators install them in their workspaces. Each plugin undergoes a security review before publication, and administrators can restrict which plugins are available in their workspace. Plugin usage is metered for billing (some plugins charge per-user per-month) and monitored for abuse (rate limiting, resource usage tracking).

18. Offline-First Architecture

An offline-first architecture goes beyond simply supporting offline editing — it means the application is built with the assumption that the network is unreliable and that the local state is the primary source of truth. The user should never see a "you are offline" error message or be unable to perform any operation due to network connectivity. All operations — editing, commenting, sharing, searching — should work offline with full functionality, syncing seamlessly when connectivity is restored.

Local Storage Architecture

The client maintains a local SQLite database (using SQLCipher for encryption) that contains: the complete CRDT state of all recently accessed documents, the operation outbox (pending operations not yet synced), user preferences, and cached search indexes. The local database is the single source of truth for the client — the application never reads from the server for display purposes. When the network is available, the client syncs with the server in the background, pushing outbox operations and pulling remote operations.

C#
public class OfflineFirstDocumentStore
{
    private readonly SQLiteConnection _localDb;
    private readonly IRemoteSyncClient _remoteSync;
    private readonly INetworkMonitor _network;

    public OfflineFirstDocumentStore()
    {
        _localDb = new SQLiteConnection(
            new SqliteConnectionString
            {
                DataSource = "collaborative-editor.db",
                Password = GetEncryptionKey()
            });
        InitializeSchema();
    }

    public async Task<CRDTDocument> OpenDocumentAsync(string documentId)
    {
        var local = await LoadLocalAsync(documentId);

        if (_network.IsConnected)
        {
            _ = Task.Run(async () =>
            {
                try
                {
                    await SyncDocumentAsync(documentId);
                }
                catch (Exception ex)
                {
                    LogSyncError(ex);
                }
            });
        }

        return local;
    }

    public async Task ApplyEditAsync(string documentId, CRDTOperation op)
    {
        var doc = await LoadLocalAsync(documentId);
        doc.Apply(op);

        await SaveLocalAsync(documentId, doc);
        await AppendToOutboxAsync(documentId, op);

        if (_network.IsConnected)
        {
            await FlushOutboxAsync(documentId);
        }
    }

    private async Task SyncDocumentAsync(string documentId)
    {
        var localStateVector = await GetLocalStateVectorAsync(documentId);
        var remoteOps = await _remoteSync.GetMissingOperationsAsync(
            documentId, localStateVector);

        var outboxOps = await GetOutboxOperationsAsync(documentId);
        var syncResult = await _remoteSync.SyncAsync(
            documentId, localStateVector, outboxOps);

        var doc = await LoadLocalAsync(documentId);

        foreach (var op in remoteOps)
        {
            doc.Apply(op);
        }

        await SaveLocalAsync(documentId, doc);
        await AcknowledgeOutboxAsync(
            documentId, syncResult.ConfirmedIds);
    }
}

Conflict-Free Search

Full-text search must also work offline. The client maintains a lightweight search index in SQLite using FTS5 (Full-Text Search 5). When documents are synced, the search index is updated incrementally. Search queries are executed entirely on the client, with results ranked by relevance and recency. When the network is available, server-side search is used for documents not stored locally (providing "search everything" vs. "search downloaded documents").

Offline Capability Matrix: All editing operations work offline. Commenting works offline (comments are synced when reconnected). Sharing permissions can be viewed offline (cached) but changes require connectivity. Version history is available for locally stored documents. Search works offline for downloaded documents. Presence (cursors, online status) requires connectivity (by definition). Plugin installations require connectivity. Export and print work offline for locally stored documents.

19. Monitoring, Metrics & Observability

A collaborative editing system generates a wealth of operational data that must be captured, analyzed, and acted upon. Monitoring covers three pillars: metrics (numerical time-series data), logs (structured event records), and traces (distributed request traces). The goal is to detect issues before users are affected, understand system behavior under load, and diagnose problems quickly when they occur.

Key Metrics

MetricCategoryTargetAlert Threshold
Keystroke-to-remote latency (P95)Latency< 100ms> 200ms
CRDT merge time (P99)Performance< 5ms> 20ms
WebSocket connection countCapacityMonitoring> 80% of max
Document sync latency (P95)Latency< 500ms> 2s
Operation queue depthBackpressure< 100> 1000
CRDT document size (P99)Memory< 10MB> 50MB
Sync conflict rateHealth< 0.1%> 1%
Offline reconnect success rateReliability> 99.9%< 99%
Document save latency (P95)Persistence< 100ms> 500ms
Active documents per serverCapacityMonitoring> 10,000

Alerting Strategy

Our alerting follows a three-tier model: Page (immediate response required, page on-call engineer): P95 latency exceeds 2x target for 5 minutes, error rate exceeds 1% for 2 minutes, or data loss detected. Ticket (investigate within 4 hours): P95 latency exceeds target for 15 minutes, disk usage exceeds 80%, or CRDT merge time exceeds target. Log (review in daily standup): resource usage trending toward thresholds, unusual traffic patterns, or slow but non-critical operations.

Distributed Tracing

Every operation that flows through the system — from client keystroke to server merge to broadcast — is instrumented with OpenTelemetry traces. Each trace includes spans for: client operation generation, WebSocket transmission, server processing, CRDT merge, persistence, broadcast, and client-side rendering. This enables end-to-end latency analysis and makes it easy to identify bottlenecks. For example, if a particular document has high latency, we can trace individual operations to determine whether the bottleneck is in CRDT merge (large document), persistence (slow disk), or broadcast (many connected users).

C#
public class InstrumentedCRDTService
{
    private readonly ICRDTService _inner;
    private readonly Tracer _tracer;

    public async Task<CRDTOperation> MergeOperationAsync(
        string documentId, CRDTOperation op)
    {
        using var span = _tracer.StartActiveSpan("crdt.merge");
        span.SetAttribute("document_id", documentId);
        span.SetAttribute("operation.type", op.Type.ToString());
        span.SetAttribute("operation.size", op.SizeBytes);

        var sw = Stopwatch.StartNew();
        var result = await _inner.MergeOperationAsync(documentId, op);
        sw.Stop();

        span.SetAttribute("merge.duration_ms", sw.ElapsedMilliseconds);
        span.SetAttribute("document.current_size",
            result.DocumentSizeBytes);

        if (sw.ElapsedMilliseconds > 20)
        {
            span.AddEvent("slow_merge", new Dictionary<string, object>
            {
                ["threshold_ms"] = 20,
                ["actual_ms"] = sw.ElapsedMilliseconds,
                ["document_size"] = result.DocumentSizeBytes
            });
        }

        return result;
    }
}

User Experience Monitoring

Beyond infrastructure metrics, we monitor user experience metrics through client-side telemetry: time-to-interactive after page load, editor readiness time (when the user can start typing), perceived latency (smoothness of remote cursor animation), and error rates in the editor. This telemetry is sampled at 10% to balance observability with privacy, and users are informed of telemetry collection in the privacy policy.

20. Security — E2E Encryption, Access Control & Compliance

Security in collaborative editing is paramount because documents often contain sensitive business information, personal data, legal content, and intellectual property. The security architecture must address confidentiality (only authorized users can read the document), integrity (document content cannot be tampered with), availability (the system remains accessible under attack), and compliance (meeting regulatory requirements like GDPR, HIPAA, and SOC 2).

End-to-End Encryption (E2E)

For the highest security tier, our system supports end-to-end encryption where the server never sees plaintext document content. The encryption architecture uses a per-document key pair: when a document is created, a random 256-bit AES key is generated. This key is encrypted for each authorized user using their public key (via ECDH key agreement) and stored in an encrypted key registry. When a user edits the document, their client encrypts the CRDT operations with the document key before sending them to the server. The server stores and routes encrypted operations without ever being able to decrypt them.

C#
public class EndToEndEncryptionService
{
    private readonly IKeyRegistry _keyRegistry;

    public EncryptedOperation EncryptOperation(
        CRDTOperation op, DocumentKey documentKey)
    {
        var plaintext = op.Serialize();
        var nonce = RandomNumberGenerator.GetBytes(12);
        var ciphertext = AesGcm.Encrypt(
            documentKey.EncryptionKey, nonce, plaintext, 
            out byte[] tag);

        return new EncryptedOperation
        {
            Ciphertext = ciphertext,
            Nonce = nonce,
            AuthTag = tag,
            KeyId = documentKey.KeyId
        };
    }

    public CRDTOperation DecryptOperation(
        EncryptedOperation encrypted, DocumentKey documentKey)
    {
        var plaintext = AesGcm.Decrypt(
            documentKey.EncryptionKey,
            encrypted.Nonce,
            encrypted.Ciphertext,
            encrypted.AuthTag);

        return CRDTOperation.Deserialize(plaintext);
    }

    public async Task<DocumentKey> GenerateDocumentKeyAsync(
        string documentId, List<string> userIds)
    {
        var key = RandomNumberGenerator.GetBytes(32);
        var encryptedKeys = new Dictionary<string, byte[]>();

        foreach (var userId in userIds)
        {
            var userPublicKey = await _keyRegistry
                .GetUserPublicKeyAsync(userId);
            var sharedSecret = ECDH.DeriveSharedSecret(
                await GetServerPrivateKeyAsync(), userPublicKey);
            var encryptedKey = AesGcm.Encrypt(
                sharedSecret, RandomNumberGenerator.GetBytes(12),
                key, out _);
            encryptedKeys[userId] = encryptedKey;
        }

        return new DocumentKey
        {
            DocumentId = documentId,
            EncryptedKeys = encryptedKeys,
            CreatedAt = DateTime.UtcNow
        };
    }
}

E2E Encryption Trade-Offs

E2E encryption has significant implications for server-side features. The server cannot perform full-text search on encrypted content (search must be done client-side or using encrypted search techniques like oblivious RAM). The server cannot render document previews (thumbnails, share previews). The server cannot enforce content policies (profanity filtering, DLP). Version history stores encrypted operations, which are still useful for undo/redo but cannot be browsed on the web. Our system offers E2E as an opt-in feature for documents that require maximum confidentiality, while non-E2E documents benefit from full server-side functionality.

Access Control Security

All API requests are authenticated using short-lived JWTs (15-minute expiry) with refresh tokens (30-day expiry, rotated on use). Authorization is enforced at the API gateway level (coarse-grained: is the user authenticated?) and at the service level (fine-grained: does the user have permission for this specific operation on this specific document?). WebSocket connections are authenticated during the handshake using the same JWT, and re-authenticated on refresh.

Compliance

RegulationRequirementsOur Implementation
SOC 2 Type IIAudit logging, access controls, data retentionAudit trail, RBAC, configurable retention policies
GDPRData minimization, right to erasure, consentData export, deletion API, consent management
HIPAAEncryption at rest and in transit, access loggingAES-256 encryption, TLS 1.3, audit trail
CCPAConsumer data rights, opt-out of saleData export, deletion API, no data sale
ISO 27001Information security managementSecurity policies, incident response, risk assessment
Security Anti-Patterns to Avoid: Never store document content in plaintext logs. Never include document content in error messages or stack traces. Never allow WebSocket connections without authentication. Never trust client-side permission checks — always validate on the server. Never use a user-supplied document ID to look up resources without first verifying the user has access. Never expose internal IDs, user emails, or other PII in URLs or client-side code.

21. Cost Estimation

Understanding the infrastructure cost of a collaborative editing system is essential for capacity planning and pricing. The cost drivers are: compute (servers running the collaboration engine), storage (document state, version history, media), bandwidth (WebSocket traffic, media CDN), and managed services (databases, caches, message queues). We estimate costs for a system supporting 100,000 concurrent users across 500,000 active documents.

Compute Costs

The collaboration engine is the primary compute cost. Each server instance can handle approximately 5,000 concurrent WebSocket connections with 1,000 active documents. For 100,000 concurrent users, we need 20 collaboration servers. Each server is a 4-core, 16GB RAM instance. At $0.20/hour (cloud pricing), the monthly compute cost for collaboration servers is: 20 servers × $0.20/hour × 730 hours = $2,920/month. Additional servers for API, presence, and background processing add approximately $1,500/month.

Storage Costs

Storage TypeMonthly VolumeUnit CostMonthly Cost
PostgreSQL (document state + versions)500GB$0.10/GB/month$50
Redis (presence + cache)32GB$0.05/GB/month$100
S3 (media files)5TB$0.023/GB/month$115
S3 (snapshots)1TB$0.023/GB/month$23
Elasticsearch (search index)100GB$0.10/GB/month$100
Total Storage$388

Bandwidth Costs

WebSocket traffic for 100,000 concurrent users averaging 1KB/second = 100MB/second = 8.6TB/day. At $0.09/GB for egress, the monthly bandwidth cost is approximately $23,200. However, this assumes all traffic is cross-region. With regional server deployment (collaboration servers in the same region as users), most traffic is within-region and significantly cheaper. Realistic bandwidth cost: $8,000-$12,000/month.

Total Monthly Cost Estimate

CategoryMonthly Cost
Compute (collaboration + API + workers)$4,420
Storage (database + cache + objects)$388
Bandwidth (WebSocket + CDN)$10,000
Managed services (monitoring, logging)$500
Security (WAF, DDoS protection)$300
Total$15,608/month
Cost per User: At $15,608/month for 100,000 concurrent users, the infrastructure cost is approximately $0.16 per concurrent user per month. For a SaaS product with 1 million registered users and 100,000 concurrent, the infrastructure cost per registered user is approximately $0.016/month — well within the margins of most SaaS pricing models ($5-$20/user/month).

22. API Design & Testing Strategy

The API design follows RESTful conventions for resource management and WebSocket for real-time collaboration. The API is versioned (v1, v2) and documented using OpenAPI 3.0. All endpoints require authentication and are rate-limited per user (100 requests/minute for REST, 1 sustained WebSocket connection per document per user).

REST API Endpoints

MethodEndpointDescription
POST/api/v1/documentsCreate a new document
GET/api/v1/documents/:idGet document metadata
PATCH/api/v1/documents/:idUpdate document metadata (title, settings)
DELETE/api/v1/documents/:idDelete a document
GET/api/v1/documents/:id/blocksGet all blocks in a document
GET/api/v1/documents/:id/versionsGet version history
GET/api/v1/documents/:id/versions/:seqGet document state at version
POST/api/v1/documents/:id/versions/:seq/restoreRestore document to a version
GET/api/v1/documents/:id/commentsGet comments on a document
POST/api/v1/documents/:id/commentsAdd a comment
PUT/api/v1/documents/:id/comments/:cid/resolveResolve a comment
GET/api/v1/documents/:id/permissionsGet document permissions
POST/api/v1/documents/:id/permissionsGrant permission
DELETE/api/v1/documents/:id/permissions/:pidRevoke permission
POST/api/v1/media/uploadUpload media file
GET/api/v1/documents/:id/auditGet audit trail
GET/api/v1/searchFull-text search across documents

WebSocket Protocol

Message TypeDirectionPayload
JoinDocumentClient → ServerDocumentId, StateVector, UserInfo
OperationClient → ServerCRDTOperation, ClientTimestamp
PresenceUpdateClient → ServerCursor, Selection, Viewport
HeartbeatClient → ServerTimestamp
OperationServer → ClientMerged CRDTOperation, ServerTimestamp
CatchUpServer → ClientList of missed operations
AckServer → ClientOperationId, ServerSequenceNumber
PresenceServer → ClientUserPresence for all users
UserJoinedServer → ClientUserId, DisplayName, Color
UserLeftServer → ClientUserId

Testing Strategy

Testing a collaborative editing system requires specialized approaches because the core correctness property (convergence) involves concurrent operations that are inherently non-deterministic. Our testing strategy has four layers:

Unit Tests (CRDT correctness): The CRDT merge function is tested exhaustively with randomly generated operation sequences. We generate 10,000 random operation sequences, apply them to the same initial state in different orders, and verify that the final state is identical in all cases. This property-based testing approach catches subtle bugs in the merge function that example-based tests would miss.

Integration Tests (WebSocket protocol): Multiple simulated clients connect to the collaboration server via WebSocket and perform concurrent edits. The test harness tracks expected convergence state and verifies that all clients converge to the expected state after all operations are delivered. Tests cover: simple concurrent inserts, concurrent deletes, insert-then-delete, formatting conflicts, and reconnection with catch-up.

Chaos Tests (network failure): Simulated network partitions, packet loss (10%, 30%, 50%), latency injection (100ms, 500ms, 2s), and client crashes during editing. The system must recover gracefully in all cases: no data loss for acknowledged operations, consistent state after reconnection, and graceful handling of partially delivered operations.

C#
[Fact]
public async Task ConvergentConcurrentEdits_AllClientsReachSameState()
{
    var server = await StartTestServer();
    var initialState = CRDTDocument.Create("hello");

    var clientA = await ConnectClientAsync(server, "doc1", initialState);
    var clientB = await ConnectClientAsync(server, "doc1", initialState);
    var clientC = await ConnectClientAsync(server, "doc1", initialState);

    clientA.ApplyLocal(InsertOperation.At(5, " world"));
    clientB.ApplyLocal(InsertOperation.At(0, "Oh, "));
    clientC.ApplyLocal(InsertOperation.At(5, "!"));

    await Task.Delay(2000);

    var stateA = clientA.Document.ToString();
    var stateB = clientB.Document.ToString();
    var stateC = clientC.Document.ToString();

    Assert.Equal(stateA, stateB);
    Assert.Equal(stateB, stateC);

    Assert.Contains("hello", stateA);
    Assert.Contains(" world", stateA);
    Assert.Contains("Oh, ", stateA);
    Assert.Contains("!", stateA);
}

[Theory]
[InlineData(10)]
[InlineData(100)]
[InlineData(1000)]
public async Task RandomOperationOrdering_AlwaysConverges(int operationCount)
{
    var document = CRDTDocument.Create("initial");

    var operations = GenerateRandomOperations(operationCount);

    var permutations = GenerateRandomPermutations(operations, 20);

    var finalStates = new List<string>();
    foreach (var permutation in permutations)
    {
        var doc = CRDTDocument.Copy(document);
        foreach (var op in permutation)
        {
            doc.Apply(op);
        }
        finalStates.Add(doc.ToString());
    }

    Assert.All(finalStates, state =>
        Assert.Equal(finalStates[0], state));
}

Performance Tests: Load testing with simulated clients performing realistic editing patterns. We use a custom load generator that simulates typing at 60 characters per minute (average human typing speed), with bursts of paste operations, formatting changes, and cursor movements. The test measures: keystroke-to-remote latency under load, memory usage as document size grows, and server throughput as concurrent user count increases.

23. Interview Q&A Deep Dive

This section covers the most frequently asked interview questions about collaborative editing system design, with detailed answers that demonstrate senior-level understanding.

Q1: What is the difference between OT and CRDT? When would you choose one over the other?

Answer: OT (Operational Transformation) and CRDT (Conflict-free Replicated Data Types) are both approaches to achieving consistency in collaborative editing without requiring locks or consensus. OT represents edits as operations that are transformed against concurrent operations to maintain a consistent state. It typically requires a central server to coordinate transformations. CRDTs use mathematically designed data structures where the merge operation is defined by the data structure itself and is commutative, associative, and idempotent — meaning replicas can merge independently without coordination.

Choose OT when: you have a reliable central server, you need maximum bandwidth efficiency (OT operations are very compact), and you have the engineering expertise to implement complex transform functions correctly. Choose CRDT when: you need offline-first capability, you want simpler correctness guarantees, you are building a peer-to-peer or decentralized system, or your document model includes rich structure (blocks, tables, embeds) where CRDT composition is more natural than OT transformation.

Q2: How do you handle the "undo" problem in collaborative editing?

Answer: Undo in collaborative editing must only affect the current user's changes, not other users' changes. The approach is to maintain per-user undo stacks where each entry stores the inverse of the original operation. When the user undoes, the inverse operation is applied, transformed against any operations that occurred since the original operation. This is called "undo with transform" and ensures that the undo is semantically correct even when the document has changed due to other users' edits. The key insight is that undo is not "revert to a previous state" — it is "apply the inverse of my specific change, adjusted for everything that has happened since."

Q3: How does the system handle a user who has been offline for 2 hours?

Answer: When the user reconnects, the client performs a state vector exchange with the server. The client sends its state vector (summarizing all operations it has seen), and the server responds with all operations from other users that occurred during the disconnection. The client applies these operations to its local CRDT state, then sends its outbox operations (the user's offline edits) to the server. The server merges both sets of operations. Because CRDTs are commutative and idempotent, the final state is consistent regardless of the order in which operations are applied. The user sees their offline edits seamlessly integrated with the changes made by others.

Q4: How do you scale the system to support millions of concurrent users?

Answer: Horizontal scaling is achieved through document-based sharding: each document is assigned to a specific collaboration server based on a consistent hash of the document ID. All operations for a document are routed to the same server, avoiding cross-server coordination. When a server's load exceeds a threshold, documents are migrated to less loaded servers. Presence is sharded by document ID as well, using Redis Pub/Sub for cross-server broadcasting. The persistence layer uses read replicas for document reads and connection pooling for database connections. For very popular documents (thousands of concurrent editors), we use a "fan-out" architecture where a single collaboration server handles the document but broadcasts through a dedicated presence server.

Q5: How do you ensure data durability and prevent data loss?

Answer: Data durability is ensured through a write-ahead log (WAL) approach: every operation is persisted to PostgreSQL's WAL before being acknowledged to the client. The WAL is replicated to at least 3 nodes using PostgreSQL streaming replication. Periodically (every 1000 operations), a snapshot is saved to S3-compatible storage with cross-region replication. This creates three layers of data protection: WAL replication (survives node failure), snapshot to S3 (survives datacenter failure), and cross-region replication (survives region failure). In the event of a server crash, the server replays the WAL to recover its in-memory state, ensuring no acknowledged operations are lost.

Q6: How would you test the correctness of the CRDT implementation?

Answer: CRDT correctness is tested using property-based testing. The fundamental property to verify is "commutativity of merge": given any two operations op1 and op2, applying op1 then op2 produces the same state as applying op2 then op1. We generate thousands of random operation sequences, apply them to the same initial state in different orders, and verify that the final state is identical. We also test "idempotency": applying the same operation twice produces the same state as applying it once. For more complex scenarios, we use a "random schedule" approach: generate N random operations from M simulated clients, deliver them in random order with random batching, and verify all clients converge. We run these tests with increasing scale (10 operations, 100, 1000, 10000) to catch issues that only appear at scale.

Q7: How do you handle permissions at the block level?

Answer: Block-level permissions use a hierarchical model: workspace permissions inherit to documents, document permissions inherit to blocks, and block-level permissions can restrict (but never expand) inherited permissions. When a user performs an operation, the server checks the effective permission at the most specific level: if the operation targets a specific block, block-level permissions are checked; otherwise, document-level permissions are checked. The permission resolution is cached per user per document to minimize database lookups. The server rejects any operation that exceeds the user's effective permission level and sends an error to the client, which displays an appropriate message and reverts the local change.

Q8: How do you optimize for very large documents (500+ pages)?

Answer: Large documents present challenges in three areas: CRDT memory usage, rendering performance, and sync bandwidth. For CRDT memory, we use a "lazy CRDT" approach where only the document's structural metadata (block IDs and ordering) is held in memory, and block content is loaded on demand. For rendering, we use viewport virtualization — only visible blocks are rendered in the DOM. For sync bandwidth, we use operation compression: consecutive character insertions are batched into single operations, and formatting changes are coalesced. Additionally, we use incremental state vectors that track changes at the block level, so a client editing block 500 does not need to receive updates for blocks 1-499.

Q9: What are the security considerations for a collaborative editing system?

Answer: Security considerations span authentication, authorization, data protection, and compliance. Authentication uses short-lived JWTs with refresh token rotation. Authorization is enforced at the API gateway (is the user authenticated?) and service level (does the user have permission for this specific operation?). Data protection includes encryption at rest (AES-256), encryption in transit (TLS 1.3), and optional end-to-end encryption for sensitive documents. The audit trail provides a tamper-evident record of all actions. Rate limiting prevents abuse. Input validation prevents injection attacks. Content Security Policy (CSP) headers prevent XSS. All of this is complemented by regular penetration testing and a bug bounty program.

Q10: How do you design the API for a collaborative editing system?

Answer: The API is split into two interfaces: REST for resource management (CRUD on documents, permissions, comments, search) and WebSocket for real-time collaboration (operation exchange, presence updates). The REST API follows resource-oriented design with standard HTTP methods and status codes. The WebSocket protocol uses a binary message format for efficiency, with message types for operations, presence, sync, and lifecycle events. Both interfaces share the same authentication mechanism (JWT) and authorization model (permission checks). The API is versioned (v1, v2) to allow non-breaking changes. Rate limiting is applied per-user: 100 REST requests/minute and 1 sustained WebSocket connection per document per user.

Interview Tip: When discussing collaborative editing in system design interviews, always clarify the requirements early: Is offline editing required? How many concurrent editors per document? Is the content text-only or rich (blocks, images, tables)? What is the latency requirement? The answers to these questions drive the choice between OT and CRDT, the persistence strategy, and the overall architecture complexity. A system designed for "2 users editing a plain text file" is vastly simpler than "50 users editing a rich document with blocks, images, and comments offline."

Collaborative Editing System — Senior+ Guide | Ayodhyya