How to Design a Real-Time Collaboration Engine (CRDT-based) — A Senior+ Guide
Article #171 — A comprehensive deep-dive into building conflict-free, real-time collaborative systems
1. Introduction and Collaboration Fundamentals
Real-time collaboration has become a foundational capability in modern software. From Google Docs to Figma, from Notion to VS Code Live Share, the ability for multiple users to simultaneously edit shared documents, design canvases, or spreadsheets is no longer a luxury but an expectation. At the heart of these systems lies a critical engineering challenge: how do you maintain consistency across multiple replicas of data that are being concurrently modified by different users, potentially on different machines, with unreliable network conditions?
Historically, the dominant approach to solving this problem has been Operational Transformation (OT), a technique pioneered in the 1980s and 90s by researchers like Ellis, Gibbs, and Gresli, and later popularized by Google Docs. OT works by transforming operations against each other so that they can be applied in any order while achieving the same final state. However, OT comes with significant complexity: the transformation functions are notoriously difficult to implement correctly, they must be maintained on a central server, and the protocol becomes increasingly brittle as the number of concurrent operations grows.
In recent years, a fundamentally different approach has gained traction: Conflict-Free Replicated Data Types, or CRDTs. First formally described by Shapiro, Preguiça, Baquero, and Zawirski in a series of academic papers between 2011 and 2018, CRDTs are data structures that can be replicated across multiple nodes and modified independently, with a mathematical guarantee that all replicas will eventually converge to the same state without requiring any coordination or consensus protocol. This property, known as strong eventual consistency (SEC), makes CRDTs uniquely suited to building collaboration engines that must work across unreliable networks, support offline editing, and scale to large numbers of concurrent users.
This article provides a comprehensive, senior-level deep-dive into the design of a CRDT-based real-time collaboration engine. We will cover the theoretical foundations of CRDTs, compare them with Operational Transformation, walk through the complete system architecture, examine each major subsystem from the transport layer to cursor presence to offline sync, and provide production-quality C# code examples throughout. Whether you are designing a collaborative text editor, a shared whiteboard, a multi-user spreadsheet, or any other collaborative application, the patterns and principles discussed here will give you the architectural foundation to build it correctly.
What Makes Collaboration Hard?
Before diving into solutions, it is important to understand precisely what makes real-time collaboration such a difficult engineering problem. The fundamental challenge is that every connected client maintains a local replica of the shared document state. When a user makes an edit, that edit is first applied locally for immediate feedback, and then transmitted to other peers. In a world with network latency, partitions, and concurrent edits, several problems arise:
- Concurrent modifications: Two users may edit the same paragraph or even the same word at the same time. The system must determine a consistent outcome.
- Out-of-order delivery: Operations may arrive at a peer in a different order than they were generated, especially when transmitted through different network paths.
- Network partitions: A user may go offline, continue editing, and then reconnect. Their edits must be seamlessly integrated without data loss.
- Byzantine clients: Malicious or buggy clients may send operations that violate invariants. The server must validate operations without trusting clients.
- Scalability: As the number of concurrent editors grows, the system must handle the increased throughput of operations without degrading latency or correctness.
Each of these challenges has been addressed by various approaches over the decades, but CRDTs offer a uniquely elegant solution because they push correctness into the data structure itself. If the data type is a valid CRDT, convergence is mathematically guaranteed regardless of the order in which operations are received. This eliminates an entire class of bugs related to operation ordering and transformation, which have plagued OT-based systems for decades.
Real-World Applications
The scope of CRDT-based collaboration extends far beyond text editing. Modern applications of CRDTs include collaborative code editing (Replit, CodeSandbox), real-time design tools (Figma uses a proprietary CRDT-like approach), shared spreadsheets, multiplayer gaming state synchronization, distributed databases (like Redis CRDB), IoT sensor data aggregation, and collaborative music production. Any system that requires multiple nodes to independently modify shared state and eventually converge can benefit from CRDTs.
In this article, we will focus primarily on text editing as the canonical use case, because it is the most complex and well-studied application of CRDTs. The same principles apply to other data types including lists, maps, counters, and registers, and we will cover those as well. By the end of this guide, you will have a complete understanding of how to design, implement, and operate a production-quality CRDT-based collaboration engine.
Article Scope and Prerequisites
This article is written for senior software engineers and architects who have a solid understanding of distributed systems, networking, and data structures. We assume familiarity with concepts like eventual consistency, vector clocks, and WebSocket protocols. The code examples are written in C# and target .NET 8+, though the architectural patterns are language-agnostic. We will reference several open-source CRDT implementations including Yjs, Automerge, and Diamond Types and explain how they work under the hood, but our focus is on building a complete system covering not just the CRDT data structure but the entire stack from the transport layer to the persistence layer.
Let us begin by examining the two dominant approaches to building collaborative systems, Operational Transformation and CRDTs, and understanding why the industry is shifting toward CRDTs for most use cases.
2. OT vs CRDT: Consistency Models Compared
Understanding the fundamental differences between Operational Transformation (OT) and Conflict-Free Replicated Data Types (CRDTs) is essential for making informed architectural decisions when building a collaboration engine. Both approaches solve the same problem of maintaining consistency across concurrent edits but they do so through fundamentally different mechanisms, each with distinct trade-offs.
Operational Transformation: How It Works
OT was first described by Charles Ellis and Gibbs in their 1989 paper on collaborative editing. The core idea is elegant: instead of transmitting the entire document state after each edit, users transmit operations such as insert character at position 5 or delete character at position 12. When a peer receives an operation that was generated before its own local operations, it must transform the incoming operation so that it is correct relative to the local operations that have already been applied.
For example, suppose user A inserts the letter X at position 5, and user B simultaneously inserts the letter Y at position 7. If user B receives A's operation first, B must transform it to account for B's own insertion. Since B inserted before position 7, A's insertion at position 5 might need to be adjusted depending on the exact interleaving. The transformation function T(opA, opB) produces a new operation opA' such that applying opA' after opB yields the same result as applying opA before opB. This property is called the transformation property.
The key properties that OT transformation functions must satisfy are: (1) TP1 Transform and compose: T(T(op1, op2), op3) = T(op1, T(op2, op3)) meaning the order of transformations does not matter; (2) TP2 Invert and compose: T(op1, op2) composed with T(op2, op1) equals identity meaning transforming an operation by its inverse undoes it; and (3) TP3 Convergence: if two peers start from the same state and apply the same set of operations possibly in different orders they must reach the same final state.
These properties are straightforward for simple character-level insert/delete operations but become extraordinarily complex for rich text including bold, italic, nested formatting, structural operations including paragraph splits and list item reordering, and concurrent undo/redo. Google's Jupiter system, which powers Google Docs, reportedly has over 100 transformation rules for its rich text operations. Each new feature requires careful analysis of its interaction with existing operations and the transformation functions must be updated accordingly.
CRDTs: How They Work
CRDTs take a fundamentally different approach. Rather than transforming operations to maintain a single consistent state, CRDTs define data structures with special algebraic properties that guarantee convergence regardless of the order in which updates are received. The key insight is that if the states form a join-semilattice, a partially ordered set where every pair of elements has a least upper bound, then merging states is simply computing their join which is commutative, associative, and idempotent.
There are two main flavors of CRDTs: (1) State-based CRDTs (CvRDTs) where each replica periodically sends its entire state to other replicas and the receiving replica merges the incoming state with its own using a merge function that must be commutative, associative, and idempotent so messages can arrive in any order, be duplicated, or be lost without affecting correctness; and (2) Operation-based CRDTs (CmRDTs) where each replica broadcasts individual operations to other replicas and the operations must be delivered exactly once but can be delivered in any order, with each operation commuting with all concurrent operations. CmRDTs are more bandwidth-efficient but require a reliable broadcast layer.
| Property | Operational Transformation (OT) | CRDTs |
|---|---|---|
| Coordination requirement | Central server required for ordering | No coordination required |
| Network model | Requires reliable, ordered delivery | Tolerates reordering, duplication, loss |
| Offline support | Limited; requires complex merge on reconnect | Native; merges on reconnect automatically |
| Convergence guarantee | Depends on correctness of transformation functions | Mathematically guaranteed by algebraic properties |
| Complexity of implementation | High; transformation functions grow with features | Moderate; data structure complexity is bounded |
| Rich text support | Well-studied with many production implementations | Improving rapidly (Yjs, Automerge); requires careful design |
| Bandwidth efficiency | Very efficient; only operations transmitted | Can be less efficient; state-based sends full state |
| P2P support | Difficult without a central server | Natural fit for P2P architectures |
| Undo/redo | Well-understood with undo stacks | Complex; requires inverse operations or tombstones |
| Industrial adoption | Google Docs, Apache Wave, ShareJS | Figma, Apple Notes, Redis CRDB, Yjs ecosystem |
The Convergence Debate
The fundamental question when choosing between OT and CRDTs is: do you want to manage convergence through protocol-level coordination (OT) or through data structure-level guarantees (CRDTs)? In practice, the answer often depends on your use case. If you are building a collaborative text editor with rich formatting and complex structural operations, OT or a hybrid approach may still be the pragmatic choice because of the mature ecosystem of transformation functions. If you are building a collaborative application that needs to work offline, across unreliable networks, or in a P2P topology, CRDTs are almost always the better choice.
The industry trend is clearly moving toward CRDTs. Apple uses CRDTs in iCloud Notes for collaborative editing across devices. Figma's multiplayer engine is based on CRDT-like data structures. The Yjs framework, which implements a CRDT-based approach, has become the most popular open-source library for building collaborative editors. Automerge, backed by Ink and Switch, provides a JSON-like CRDT that is gaining widespread adoption. Even Google has published research on using CRDTs for collaborative spreadsheets.
Hybrid Approaches
In practice, many production systems use a hybrid approach that combines the strengths of both OT and CRDTs. For example, a system might use CRDTs for the core document state including text content and paragraph structure while using OT-like transformation for cursor positions and selection state. Or it might use OT for the real-time collaborative editing path for maximum bandwidth efficiency while using CRDTs for offline sync and merge for robustness. The key insight is that OT and CRDTs are not mutually exclusive; they address different aspects of the collaboration problem and can be composed effectively.
In the rest of this article, we will focus primarily on the CRDT-based approach, because it provides the strongest guarantees for the most challenging aspects of real-time collaboration including offline support, P2P connectivity, and convergence correctness. However, we will also discuss where OT-like patterns are still useful, particularly in the cursor presence and awareness subsystem.
3. CRDT Theory Deep Dive
To build a production-quality CRDT-based collaboration engine, it is not enough to simply use an existing CRDT library. A senior engineer must understand the mathematical foundations that make CRDTs work, because this understanding informs architectural decisions at every level of the system from data structure selection to network protocol design to persistence strategy. In this section, we dive deep into the theory of CRDTs, covering semilattices, monotonicity, causality, and the two main families of CRDT implementations.
Join-Semilattice and Monotonicity
The foundational mathematical concept behind state-based CRDTs is the join-semilattice. A semilattice is an algebraic structure consisting of a set S equipped with a binary operation called join or meet that is associative, commutative, and idempotent. For CRDTs, we use the join-semilattice, where the join operation computes the least upper bound of two elements.
A partially ordered set (S, less-than-or-equal) is a join-semilattice if for every pair of elements a, b in S, there exists a least upper bound a join b such that: (1) Commutativity holds: a join b = b join a; (2) Associativity holds: (a join b) join c = a join (b join c); and (3) Idempotency holds: a join a = a.
The critical insight is that if a CRDT replica's local state always moves upward in the semilattice (i.e., the state monotonically increases according to the partial order), then merging two states is simply computing their join. Because the join operation is commutative, associative, and idempotent, it does not matter in which order merges are performed; the final state will always be the same. This is the source of the strong eventual consistency guarantee.
Monotonicity is the key constraint that CRDT designers must satisfy. Every operation on a CRDT must move the state upward in the partial order. This means that some operations that seem natural, like deleting an element and then re-inserting it, are not directly possible in a monotonic CRDT. Instead, deletions must be represented through mechanisms like tombstones (marking an element as deleted without removing it from the data structure) or versioning (keeping all versions and selecting the most recent one).
Causal Consistency and Happens-Before
While CRDTs guarantee eventual convergence, they do not guarantee that all replicas observe operations in the same order. Two operations that are causally unrelated (i.e., neither happened before the other) may be observed in different orders by different replicas. This is fine for correctness because the CRDT properties ensure convergence regardless of order, but it has implications for the user experience. For example, if user A types hello and user B who has not seen A's typing types world, one replica might show helloworld while another shows worldhello. Both are valid outcomes of the CRDT merge, but they may surprise users.
The happens-before relation, introduced by Leslie Lamport in 1978, formalizes causal ordering. Operation A happened-before operation B (written A arrow B) if: (1) A was generated before B on the same replica, or (2) A was transmitted to another replica which then generated B. Operations that are not ordered by happens-before are called concurrent. CRDTs ensure that concurrent operations commute, but causally ordered operations are always applied in causal order.
Version vectors, which we will discuss in detail in a later section, are used to track causality. Each replica maintains a vector of counters, one per replica, that tracks which operations from each replica have been observed. When a replica receives a new operation, it can determine whether the operation is causally subsequent to its current state or whether it is concurrent with some local operations and thus requires a merge.
State-Based (CvRDT) vs Operation-Based (CmRDT)
The choice between state-based and operation-based CRDTs is one of the most important architectural decisions in a collaboration engine. Each approach has distinct trade-offs in terms of bandwidth, latency, implementation complexity, and suitability for different network topologies.
State-Based CRDTs (CvRDTs)
In a state-based CRDT, each replica maintains a local state that evolves monotonically according to a partial order. Replicas synchronize by exchanging their full local states and merging them. The merge function must be commutative, associative, and idempotent. These properties ensure that synchronization works correctly even if messages are reordered, duplicated, or lost. State-based CRDTs are simple to implement and require only an eventually reliable network, with gossip protocols working well. However, they can be bandwidth-inefficient because they transmit the entire state, which may be much larger than the incremental changes.
Operation-Based CRDTs (CmRDTs)
In an operation-based CRDT, replicas broadcast individual operations rather than full states. Each operation must commute with all concurrent operations. The broadcast layer must guarantee exactly-once, causal delivery: every operation is delivered exactly once, and causally ordered operations are delivered in causal order. Operation-based CRDTs are more bandwidth-efficient because they transmit only the delta, but they require a more sophisticated broadcast layer. In practice, the broadcast layer is often implemented on top of WebSockets with server-side ordering.
Delta-State CRDTs (delta-CRDTs)
Delta-state CRDTs, introduced by Almeida, Shoker, and Baquiero in 2016, combine the advantages of both approaches. Instead of transmitting the full state like CvRDTs or raw operations like CmRDTs, delta-state CRDTs transmit deltas which are compact representations of the state changes since the last synchronization. This approach maintains the formal properties of state-based CRDTs while achieving the bandwidth efficiency of operation-based approaches.
In a delta-state CRDT, each replica maintains not only its current state but also a delta buffer that accumulates state changes since the last synchronization. When synchronizing with another replica, the replica sends the contents of its delta buffer rather than its full state. After sending, the delta buffer is cleared. If a synchronization message is lost, the next synchronization will include the full accumulated changes since the last successful synchronization, providing the same robustness as state-based CRDTs.
Delta-state CRDTs are the approach used by most modern CRDT implementations including Yjs and Automerge. They represent the current state of the art in CRDT design and are the recommended approach for building production collaboration engines.
Commutativity, Associativity, and Idempotency in Practice
Let us make these algebraic properties concrete with a simple example: a counter CRDT. Consider a grow-only counter (G-Counter) that can only be incremented. Each replica maintains a vector of counters, one per replica. To increment, a replica increments its own entry in the vector. To read the counter value, a replica sums all entries in the vector. To merge two states, a replica takes the element-wise maximum of the two vectors.
The merge function merge(s1, s2) = max(s1[0], s2[0]), max(s1[1], s2[1]), ... is commutative because max is commutative, associative because max is associative, and idempotent because max(x, x) = x. This guarantees that all replicas converge to the same counter value regardless of the order in which they exchange states.
This diagram illustrates how three replicas, each with different local states, converge to the same state after merging. The order of merges does not matter: merging A and B first, then merging with C, yields the same result as merging B and C first, then merging with A. This is the algebraic property that makes CRDTs correct, and it holds for every CRDT implementation regardless of its complexity.
4. System Architecture Overview
Building a production-quality CRDT-based collaboration engine requires far more than just a CRDT data structure. The complete system encompasses a transport layer for real-time communication, a server-side aggregation layer for persistence and fan-out, a presence and awareness subsystem for cursor positions and user awareness, an access control layer for permissions, and a client-side rendering layer that bridges the CRDT data structure with the UI. In this section, we provide a high-level overview of the complete system architecture and then dive into each subsystem in subsequent sections.
Component Responsibilities
The architecture follows a layered design with clear separation of concerns:
- Clients: Each client maintains a local CRDT document state and a rendering engine that translates CRDT state into UI updates. Clients are responsible for generating operations based on user input, applying operations locally for immediate feedback, and transmitting operations to peers and the server.
- WebSocket Gateway: Manages persistent WebSocket connections from clients, handles connection lifecycle including authentication, heartbeat, and reconnection, and routes messages between clients and application services. The gateway is stateless and horizontally scalable.
- CRDT Aggregation Service: Receives operations from clients, validates them against the current document state, persists them to durable storage, and broadcasts them to other connected clients. The aggregation service is the server in the server-aided CRDT architecture.
- Presence and Awareness Service: Manages ephemeral state like cursor positions, selection ranges, user online status, and typing indicators. This state is stored in Redis with TTL-based expiration and is not persisted to durable storage.
- Auth and Permission Service: Validates client authentication tokens, enforces document-level permissions including read, write, and admin, and rate-limits operations to prevent abuse.
- Sync and Reconciliation Service: Handles offline sync by accepting compressed CRDT states from reconnecting clients and merging them with the server's current state. Also handles initial document sync for newly connecting clients.
Message Flow for a Typical Edit
When a user types a character in the collaborative editor, the following sequence of events occurs:
- The client's editor component captures the keystroke and generates a CRDT operation (e.g., insert character A at position 5).
- The operation is applied to the local CRDT document state, producing a new local state. The UI is re-rendered immediately for responsiveness.
- The operation is serialized and sent to the server via the WebSocket connection.
- The server receives the operation, validates it against the current document state, and persists it to the operation log in S3 or a write-ahead log.
- The server merges the operation into its own CRDT state, which is the authoritative state.
- The server broadcasts the operation to all other connected clients via their WebSocket connections.
- Each receiving client applies the operation to its local CRDT state and re-renders the UI.
This flow ensures that (a) the editing user sees their changes immediately (step 2), (b) the operation is durably persisted (step 4), and (c) all other clients see the changes with low latency (steps 6-7). If a client is offline, operations are queued locally and sent when connectivity is restored. The server-side CRDT merge ensures that offline edits are correctly integrated without data loss.
| Component | Replica Count | Scaling Strategy | Resource Profile |
|---|---|---|---|
| WebSocket Gateway | 3-10+ | Horizontal (sticky sessions or pub/sub) | CPU-bound (message routing) |
| CRDT Aggregation | 1 per document (partitioned) | Document-level sharding | CPU + Memory (CRDT merge) |
| Presence Service | 3-5 | Horizontal (Redis-backed) | Memory-bound (Redis) |
| Auth Service | 3-5 | Horizontal (stateless) | CPU-bound (JWT validation) |
| Sync Service | 3-5 | Horizontal (stateless) | CPU + I/O (CRDT merge + storage) |
| PostgreSQL | 1 primary + 2 read replicas | Read replicas for scaling reads | I/O-bound (snapshots) |
| Redis | 3 (cluster mode) | Cluster mode for sharding | Memory-bound |
| S3 | Managed (infinite) | Managed object storage | I/O-bound (CRDT logs) |
Server-Aided CRDT Architecture
In a pure peer-to-peer CRDT architecture, replicas exchange states directly with each other and converge independently. While this is theoretically elegant, it presents practical challenges: peer discovery is complex, NAT traversal is unreliable, and there is no central authority for access control, persistence, or ordering guarantees. Most production CRDT-based collaboration engines use a server-aided architecture where the server acts as a reliable relay, aggregator, and persistence layer, while clients still maintain full CRDT state and can operate independently during network partitions.
The server in a server-aided CRDT architecture performs several critical functions: (1) it provides a reliable broadcast channel for operations, eliminating the need for complex peer-to-peer discovery and NAT traversal; (2) it persists CRDT states and operation logs for durability and disaster recovery; (3) it performs access control and rate limiting; (4) it handles client reconnection and state synchronization; and (5) it can perform garbage collection of tombstones and other internal CRDT metadata that is no longer needed.
The key architectural constraint in a server-aided CRDT system is that the server must never be a single point of failure for correctness. If the server goes down, clients should be able to continue editing locally and sync when the server recovers. The CRDT's mathematical guarantees ensure that offline edits will be correctly merged, regardless of how long the client was disconnected. This is the fundamental advantage of CRDTs over OT: the server is a performance optimization and persistence layer, not a correctness requirement.
C#
public class CrdtAggregationService
{
private readonly IDocumentRepository _repository;
private readonly IOperationLog _operationLog;
private readonly IBroadcaster _broadcaster;
private readonly ILogger<CrdtAggregationService> _logger;
public CrdtAggregationService(
IDocumentRepository repository,
IOperationLog operationLog,
IBroadcaster broadcaster,
ILogger<CrdtAggregationService> logger)
{
_repository = repository;
_operationLog = operationLog;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task<OperationResult> HandleOperationAsync(
string documentId, string clientId, CrdtOperation operation)
{
var document = await _repository.GetOrCreateAsync(documentId);
if (!await ValidateOperationAsync(document, clientId, operation))
return OperationResult.Rejected("Operation validation failed");
await _operationLog.AppendAsync(documentId, clientId, operation);
var mergeResult = document.CrdtState.Merge(operation);
await _repository.SaveStateAsync(documentId, mergeResult.NewState);
await _broadcaster.BroadcastAsync(documentId, clientId, operation);
_logger.LogInformation(
"Operation applied: doc={DocId}, client={ClientId}, op={OpType}",
documentId, clientId, operation.Type);
return OperationResult.Accepted(mergeResult.Version);
}
private async Task<bool> ValidateOperationAsync(
CollaborativeDocument document, string clientId, CrdtOperation operation)
{
if (!document.Permissions.CanEdit(clientId)) return false;
if (operation.Timestamp < document.LastCompactionTimestamp) return false;
return true;
}
}
This C# implementation demonstrates the core aggregation loop: receive an operation, validate it, persist it, merge it into the authoritative CRDT state, and broadcast it to other clients. Each step is designed to be idempotent and fault-tolerant. If the server crashes after persisting the operation but before broadcasting, the operation is still durably stored and will be re-broadcast on recovery. If the broadcast fails for a specific client, that client will receive the operation on its next reconnect during the sync reconciliation process.
5. WebSocket and WebRTC Transport Layer
The transport layer is the foundation of any real-time collaboration system. It is responsible for delivering CRDT operations between clients and the server with minimal latency, handling connection lifecycle events, and providing fallback mechanisms for environments where WebSockets are unavailable. In this section, we examine the design of the WebSocket and WebRTC transport layers, including connection management, message framing, heartbeat mechanisms, and reconnection strategies.
WebSocket Protocol Design
WebSocket provides a full-duplex, persistent communication channel between client and server, making it the ideal transport for real-time collaboration. Unlike HTTP long-polling or server-sent events, WebSockets support bidirectional message flow with minimal overhead. The WebSocket connection is established via an HTTP upgrade handshake and then maintained as a persistent TCP connection.
For a collaboration engine, the WebSocket protocol must support several message types: CRDT operations as the primary payload, presence updates including cursor positions and selection ranges, synchronization messages for full state exchange during reconnection, and control messages including authentication, heartbeat, and error notifications. Each message must be self-describing so that receivers can dispatch it to the appropriate handler.
| Message Type | Direction | Payload | Frequency | Priority |
|---|---|---|---|---|
| op:insert | Client to Server to Clients | CRDT operation (character insert) | Per keystroke (debounced) | High |
| op:delete | Client to Server to Clients | CRDT operation (character delete) | Per keystroke (debounced) | High |
| op:format | Client to Server to Clients | CRDT operation (formatting change) | Per user action | Medium |
| presence:update | Client to Server to Clients | Cursor position, selection, name | Per mouse/keyboard event (throttled) | Low |
| sync:request | Client to Server | Client version vector | On reconnect | High |
| sync:state | Server to Client | Full CRDT state or delta | On reconnect | High |
| ctrl:auth | Client to Server | JWT token | On connect | Critical |
| ctrl:heartbeat | Both directions | Timestamp | Every 30s | Low |
| ctrl:error | Server to Client | Error code and message | On error | High |
Connection Management
The WebSocket connection manager must handle several lifecycle events: connection establishment, authentication, heartbeat monitoring, graceful disconnection, and reconnection. Each connected client is assigned a unique session ID, and the server maintains a mapping from session ID to connection object. When a client disconnects unexpectedly due to network failure, the server detects this via missed heartbeats and removes the session from the active connection pool.
This sequence diagram illustrates the complete lifecycle of a WebSocket connection in the collaboration engine. The authentication step ensures that only authorized clients can connect. The initial sync step ensures that the client has the latest document state before it begins receiving real-time operations. The heartbeat loop detects broken connections promptly.
WebRTC for Peer-to-Peer Acceleration
While WebSockets through a server relay provide reliable, ordered delivery, they introduce an extra network hop for every message. For latency-sensitive applications like collaborative editing, this extra hop can be significant especially for geographically distributed users. WebRTC provides a mechanism for establishing direct peer-to-peer connections between browsers, bypassing the server for data transport while still using the server for signaling and coordination.
In a hybrid WebSocket plus WebRTC architecture, the WebSocket connection is used for initial signaling, server-relayed broadcasting as a fallback when P2P connections are unavailable, and operations that require server-side validation or persistence. WebRTC data channels are used for direct peer-to-peer exchange of CRDT operations and presence updates, reducing latency for geographically close peers.
The WebRTC signaling flow works as follows: when a new client joins a document, the server notifies existing clients via the WebSocket connection. The existing clients and the new client exchange WebRTC offers and answers through the server using the WebSocket as a relay channel. Once the WebRTC connection is established, CRDT operations are sent directly between peers, while the server continues to receive a copy of all operations sent via WebSocket for persistence and broadcast to peers that are not directly connected via WebRTC.
C#
public class WebSocketConnectionManager
{
private readonly ConcurrentDictionary<string, ClientSession> _sessions = new();
private readonly IAuthValidator _authValidator;
private readonly ILogger<WebSocketConnectionManager> _logger;
public async Task HandleConnectionAsync(WebSocket socket, CancellationToken ct)
{
var sessionId = Guid.NewGuid().ToString("N");
var session = new ClientSession(sessionId, socket);
_sessions[sessionId] = session;
try
{
var authResult = await WaitForAuthAsync(session, ct);
if (!authResult.IsSuccess)
{
await SendErrorAsync(session, "Authentication failed", ct);
return;
}
session.UserId = authResult.UserId;
session.DocumentId = authResult.DocumentId;
await StartHeartbeatLoopAsync(session, ct);
var buffer = new byte[1024 * 64];
while (socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
var result = await socket.ReceiveAsync(
new ArraySegment<byte>(buffer), ct);
if (result.MessageType == WebSocketMessageType.Close) break;
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
await ProcessMessageAsync(session, message, ct);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Connection error for session {SessionId}", sessionId);
}
finally
{
_sessions.TryRemove(sessionId, out _);
await NotifyDisconnectAsync(session);
if (socket.State == WebSocketState.Open)
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", ct);
}
}
private async Task StartHeartbeatLoopAsync(ClientSession session, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), ct);
if (session.LastHeartbeat.AddSeconds(60) < DateTime.UtcNow)
{
_logger.LogWarning("Heartbeat timeout for session {SessionId}", session.SessionId);
break;
}
await SendControlMessageAsync(session, "ctrl:heartbeat", ct);
}
}
public async Task BroadcastToDocumentAsync(
string documentId, string excludeSessionId, string message, CancellationToken ct)
{
var targets = _sessions.Values
.Where(s => s.DocumentId == documentId && s.SessionId != excludeSessionId)
.ToList();
var tasks = targets.Select(async session =>
{
try
{
var bytes = Encoding.UTF8.GetBytes(message);
await session.Socket.SendAsync(
new ArraySegment<byte>(bytes),
WebSocketMessageType.Text, true, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send to session {SessionId}", session.SessionId);
}
});
await Task.WhenAll(tasks);
}
}
This implementation demonstrates a production-quality WebSocket connection manager with authentication, heartbeat monitoring, message routing, and graceful cleanup. The concurrent dictionary provides thread-safe session management, and the broadcast function efficiently sends messages to all connected clients for a given document while excluding the originating client.
Message Framing and Serialization
Efficient message framing is critical for performance in a collaboration engine. Each WebSocket message must be self-describing so that receivers can quickly determine its type and route it to the appropriate handler. We recommend a simple JSON-based protocol for control messages and a binary protocol for CRDT operations. The binary format uses a fixed header including message type, length, and CRC followed by a compressed payload using zstd or lz4 for minimal overhead.
The choice of serialization format affects both latency and bandwidth. JSON is human-readable and easy to debug, but it has significant overhead for compact data structures like CRDT operations. Protocol Buffers or MessagePack provide more compact serialization with schema evolution support. For CRDT operations, which are typically small and highly repetitive, even simple variable-length encoding of integer positions can significantly reduce message size.
Reconnection Strategy
Network disconnections are inevitable in real-world deployments. A robust collaboration engine must handle reconnection gracefully, resuming the session without data loss. The reconnection strategy should include exponential backoff with jitter to avoid thundering herd effects, automatic session resumption using a session token stored in the client's local storage, and state synchronization on reconnect by exchanging version vectors to determine which operations the client has missed.
When a client reconnects, it sends its current version vector to the server. The server compares this with the document's version vector and sends back only the operations that the client has missed. This delta-based synchronization minimizes the amount of data transferred during reconnection, even if the client was offline for an extended period. If the client's version vector is too old because the server has garbage-collected old operations, the server sends the full current CRDT state instead.
6. CRDT Data Structures
The choice of CRDT data structures is the most fundamental design decision in a collaboration engine. Different data types serve different purposes: simple counters for like counts and view counters, registers for single-writer fields, sequences for text and lists, and maps for structured documents. In this section, we examine the most important CRDT data structures used in collaborative editing, including G-Counters, PN-Counters, LWW-Registers, and sequence CRDTs like RGA (Replicated Growable Array). We also compare the two dominant sequence CRDT libraries: Yjs and Automerge.
G-Counter (Grow-Only Counter)
The G-Counter is the simplest CRDT: a counter that can only be incremented. Each replica maintains an array of integers, one per replica, indexed by replica ID. To increment, a replica increments its own entry. To read, a replica sums all entries. To merge, a replica takes the element-wise maximum.
C#
public class GCounter
{
private readonly string _replicaId;
private readonly Dictionary<string, long> _counts;
public GCounter(string replicaId)
{
_replicaId = replicaId;
_counts = new Dictionary<string, long>();
}
public long Value => _counts.Values.Sum();
public void Increment(long amount = 1)
{
if (!_counts.ContainsKey(_replicaId))
_counts[_replicaId] = 0;
_counts[_replicaId] += amount;
}
public GCounter Merge(GCounter other)
{
var result = new GCounter(_replicaId);
var allKeys = _counts.Keys.Union(other._counts.Keys);
foreach (var key in allKeys)
{
var localVal = _counts.GetValueOrDefault(key, 0);
var otherVal = other._counts.GetValueOrDefault(key, 0);
result._counts[key] = Math.Max(localVal, otherVal);
}
return result;
}
public bool Dominates(GCounter other)
{
return _counts.Keys.Union(other._counts.Keys)
.All(key =>
_counts.GetValueOrDefault(key, 0) >=
other._counts.GetValueOrDefault(key, 0));
}
}
The G-Counter illustrates the fundamental CRDT pattern: local operations modify only the local replica's entry in the shared state, and merging is a simple element-wise maximum. This guarantees convergence because max is commutative, associative, and idempotent.
PN-Counter (Positive-Negative Counter)
The PN-Counter extends the G-Counter to support both increments and decrements by maintaining two G-Counters: one for increments and one for decrements. The counter's value is the sum of the increment counter minus the sum of the decrement counter. This approach elegantly handles concurrent increments and decrements on different replicas.
| Data Structure | Operations | Merge Strategy | Use Case | Space Complexity |
|---|---|---|---|---|
| G-Counter | Increment | Element-wise max | Like counts, view counters | O(N) where N = replicas |
| PN-Counter | Increment, Decrement | Element-wise max on two G-Counters | Balance tracking, vote counts | O(N) where N = replicas |
| LWW-Register | Set value | Keep value with latest timestamp | User preferences, last-edited-by | O(1) |
| LWW-Element-Set | Add, Remove | Element-wise timestamp comparison | Tag sets, feature flags | O(N) where N = elements |
| OR-Set | Add, Remove | Set union of add sets | Shopping carts, multi-add sets | O(N x R) where R = replicas |
| RGA (Sequence) | Insert, Delete | Tree merge with tombstones | Text editing, lists | O(N + tombstones) |
| YATA (Yjs) | Insert, Delete | Two-pointer tree merge | Text editing (production) | O(N + tombstones) |
| Automerge Sequence | Insert, Delete | Index-based tree merge | JSON-like documents | O(N + tombstones) |
LWW-Register (Last-Writer-Wins Register)
The LWW-Register is a register (a container for a single value) that resolves concurrent writes by keeping the value with the latest timestamp. Each write operation includes a timestamp, typically a hybrid logical clock value, and the merge function simply compares timestamps and keeps the value with the larger timestamp. In case of a tie with same timestamp, a deterministic tiebreaker such as replica ID is used.
LWW-Registers are simple and efficient, but they have a significant limitation: concurrent writes to the same register result in one write being silently lost. This is acceptable for fields where last-write-wins semantics are appropriate such as a document title or a user's online status, but not for fields where concurrent writes must be preserved such as text content. For text content, a sequence CRDT like RGA or YATA is required.
RGA (Replicated Growable Array)
The RGA is a sequence CRDT designed for collaborative text editing. It represents a sequence of elements, characters in a text document, as a directed acyclic graph (DAG) where each node contains a single element, a timestamp, and pointers to its predecessor and successor nodes. Insertions create new nodes that point to the insertion point, and deletions mark nodes as tombstoned, which means logically deleted but physically retained.
The RGA merge algorithm works by comparing the timestamps of nodes in two replicas. When two replicas have different nodes at the same position due to concurrent inserts, the node with the larger timestamp comes first, or in case of a tie, the node with the smaller replica ID comes first. This deterministic ordering guarantees that all replicas converge to the same sequence, regardless of the order in which they receive operations.
Yjs vs Automerge vs Diamond Types
The three dominant production CRDT libraries for text editing are Yjs, Automerge, and Diamond Types. Each implements a different variant of the sequence CRDT, with different trade-offs in terms of performance, memory usage, and feature support.
Yjs implements the YATA (Yet Another Transformation Approach) algorithm, which uses a two-pointer system (left and right origins) to track insertion points. Yjs is the most widely used CRDT library in production, with support for rich text, nested structures, and a plugin ecosystem. It is highly optimized for performance and memory usage, with garbage collection of tombstones. Yjs is written in JavaScript and TypeScript but has C and C++ bindings for performance-critical operations.
Automerge implements a sequence CRDT based on the RGA algorithm, integrated with a JSON-like document model. Automerge is backed by Ink and Switch and emphasizes developer ergonomics, providing a JavaScript-like API for manipulating CRDT documents. Automerge is written in Rust with WASM bindings for browser use, providing excellent performance and memory safety. Recent versions (Automerge 2.x) include significant performance improvements and a new columnar encoding format.
Diamond Types is a newer CRDT implementation by Joseph Gentle, one of the authors of ShareDB. It focuses on maximum performance for text editing, using techniques from OT (operation normalization) to reduce the overhead of CRDT metadata. Diamond Types is significantly faster than both Yjs and Automerge for text editing workloads, but it has a more limited feature set with no rich text support and no map or counter types.
For a production collaboration engine, Yjs is typically the recommended choice because of its maturity, feature completeness, and large ecosystem. Automerge is a strong alternative for applications that need a JSON-like document model. Diamond Types is worth considering for high-performance text editing scenarios where raw speed is critical.
C#
public class RgaNode
{
public string Id { get; }
public string Value { get; }
public long Timestamp { get; }
public string ReplicaId { get; }
public string LeftOrigin { get; }
public string RightOrigin { get; }
public bool IsDeleted { get; set; }
public RgaNode(string id, string value, long timestamp,
string replicaId, string leftOrigin, string rightOrigin)
{
Id = id; Value = value; Timestamp = timestamp;
ReplicaId = replicaId; LeftOrigin = leftOrigin;
RightOrigin = rightOrigin; IsDeleted = false;
}
}
public class RgaSequence
{
private readonly string _replicaId;
private readonly Dictionary<string, RgaNode> _nodes;
private string _headId;
public RgaSequence(string replicaId)
{
_replicaId = replicaId;
_nodes = new Dictionary<string, RgaNode>();
_headId = "head";
_nodes[_headId] = new RgaNode(_headId, "", -1, "", "", "");
}
public void InsertAt(int index, string value)
{
var predecessor = GetNodeAtIndex(index);
var successor = GetNodeAtIndex(index + 1);
var nodeId = $"{_replicaId}:{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}:{Guid.NewGuid():N}";
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var newNode = new RgaNode(nodeId, value, timestamp, _replicaId,
predecessor?.Id, successor?.Id);
_nodes[nodeId] = newNode;
}
public void DeleteAt(int index)
{
var node = GetNodeAtIndex(index);
if (node != null) node.IsDeleted = true;
}
public void Merge(RgaSequence other)
{
foreach (var kvp in other._nodes)
{
if (_nodes.ContainsKey(kvp.Key)) continue;
_nodes[kvp.Value.Id] = kvp.Value;
InsertNodeInOrder(kvp.Value);
}
}
private void InsertNodeInOrder(RgaNode newNode)
{
var leftNode = _nodes.GetValueOrDefault(newNode.LeftOrigin);
if (leftNode == null) return;
var rightNode = _nodes.GetValueOrDefault(newNode.RightOrigin);
// RGA merge logic: insert node at the correct position
// based on left origin and timestamp ordering
}
private RgaNode? GetNodeAtIndex(int index)
{
int current = 0;
foreach (var node in GetOrderedNodes())
{
if (current == index) return node;
if (!node.IsDeleted) current++;
}
return null;
}
private IEnumerable<RgaNode> GetOrderedNodes()
{
var current = _nodes[_headId];
while (current != null)
{
yield return current;
current = GetNextNode(current);
}
}
private RgaNode? GetNextNode(RgaNode node)
{
return _nodes.Values
.Where(n => n.LeftOrigin == node.Id)
.OrderBy(n => n.Timestamp)
.ThenBy(n => n.ReplicaId)
.FirstOrDefault();
}
}
This C# implementation demonstrates the core concepts of the RGA sequence CRDT: nodes with origin pointers for tracking insertion points, tombstone-based deletion for monotonic state evolution, and the merge algorithm that deterministically resolves concurrent inserts. In production, you would use an existing library like Yjs or Automerge rather than implementing the merge algorithm from scratch, but understanding the implementation is essential for debugging, optimization, and making informed architectural decisions.
7. Document Model and Operational Transform Fallback
The document model defines how the collaborative document is represented internally and how it maps to the user-visible structure. In a CRDT-based system, the document model must bridge the gap between the CRDT data structure, which manages low-level state, and the rich, structured content that users create including paragraphs, headings, lists, tables, and embedded images. This section examines the document model design, including how to represent rich text with CRDTs, how to handle structural operations, and when an OT-like fallback is useful.
Rich Text Representation with CRDTs
Representing rich text, text with formatting like bold, italic, headings, etc., with CRDTs is more complex than plain text editing. There are two main approaches: (1) a flat sequence of characters with attribute runs, where formatting is represented as separate CRDT structures that span ranges of characters; and (2) a tree-based model, where the document is a tree of blocks such as paragraphs, headings, and lists, and each block contains a sequence of formatted text spans.
The flat sequence approach is simpler and is used by Yjs. In this model, the text content is stored in a single YArray of characters, and formatting attributes are stored in separate YArrays or YMaps that map character positions to attribute values. When a character is inserted or deleted, the attribute arrays are updated to maintain correct ranges. This approach is efficient for simple formatting but can become complex for deeply nested structures like lists or tables.
The tree-based approach is used by Automerge and more closely mirrors the internal representation of most text editors which use a tree of blocks. In this model, the document is a YMap or Automerge Map that contains an ordered list of block nodes. Each block node is itself a Map containing the block type such as paragraph, heading, list, etc., and a sequence of inline content, text with formatting. This approach is more natural for rich text editing but requires more complex CRDT merge logic for structural operations like list reordering.
| Approach | Data Structure | Rich Text Support | Structural Operations | Complexity |
|---|---|---|---|---|
| Flat sequence + attribute runs | YArray + YMap attributes | Good for inline formatting | Moderate (block splitting) | Low-Medium |
| Tree-based block model | Map of block nodes with sequences | Excellent for all rich text | Good (natural tree structure) | Medium-High |
| ProseMirror-like | Tree of nodes with attributes | Excellent (editor-native) | Excellent (editor integration) | High |
OT Fallback for Cursor Operations
While the document content is managed by CRDTs, cursor positions and selection ranges are inherently ephemeral and order-dependent. Two users may have their cursors at the same position, and the relative ordering of cursors matters for the user experience such as user A's cursor is before user B's cursor. CRDTs do not naturally handle this ordering because they are designed for eventual convergence, not for consistent real-time ordering.
For cursor operations, an OT-like approach is often more appropriate. Each cursor update includes the cursor position and a clock, a logical timestamp or version number. When a user's cursor update arrives, the receiving client transforms the cursor position against any concurrent text operations. This ensures that cursors are correctly positioned relative to the text content, even when text operations and cursor updates arrive in different orders.
This hybrid approach using CRDTs for document content and OT for cursor positions is used by many production systems, including the Yjs-based editor implementations. It combines the convergence guarantees of CRDTs for the critical document content with the responsiveness and ordering guarantees of OT for the ephemeral cursor state.
C#
public class CollaborativeDocument
{
public string DocumentId { get; }
public Y.Doc YjsDocument { get; }
public Y.Array<Y.Map<object>> Blocks { get; }
public Y.Map<object> Metadata { get; }
private readonly Dictionary<string, CursorState> _cursors;
public CollaborativeDocument(string documentId)
{
DocumentId = documentId;
YjsDocument = new Y.Doc();
Blocks = YjsDocument.GetArray<Y.Map<object>>("blocks");
Metadata = YjsDocument.GetMap<object>("metadata");
_cursors = new Dictionary<string, CursorState>();
}
public void ApplyTextOperation(
string blockId, int position, string operation, string value)
{
var block = FindBlock(blockId);
if (block == null) throw new InvalidOperationException("Block not found");
var text = block.GetText("content");
switch (operation)
{
case "insert": text.Insert(position, value); break;
case "delete": text.Delete(position, value.Length); break;
case "format":
var attrs = block.GetMap<object>("attributes");
var currentAttrs = attrs.ToDictionary(
kvp => kvp.Key.ToString(), kvp => kvp.Value);
ApplyFormatting(currentAttrs, position, value.Length, attrs);
break;
}
}
public void UpdateCursor(string userId, int blockIndex, int position,
string color, string userName)
{
var cursorState = new CursorState
{
BlockIndex = blockIndex, Position = position,
Color = color, UserName = userName,
Timestamp = DateTimeOffset.UtcNow
};
_cursors[userId] = cursorState;
var awareness = YjsDocument.GetAwareness();
awareness.SetLocalStateField("cursor", cursorState);
}
public byte[] GetStateVector() => YjsDocument.GetStateVector();
public byte[] GetDelta(byte[] stateVector) =>
YjsDocument.EncodeStateAsUpdate(stateVector);
public void ApplyDelta(byte[] delta) => YjsDocument.ApplyUpdate(delta);
private Y.Map<object>? FindBlock(string blockId)
{
for (int i = 0; i < Blocks.Length; i++)
{
var block = Blocks.Get(i);
if (block.Get("id")?.ToString() == blockId) return block;
}
return null;
}
private void ApplyFormatting(Dictionary<string, object> currentAttrs,
int position, int length, Y.Map<object> attrsMap)
{
// Toggle formatting for the given range
}
}
This implementation demonstrates a complete collaborative document model using Yjs as the CRDT engine. The document is structured as a list of blocks, each containing formatted text. Cursor management is handled separately through the Yjs awareness protocol, which provides real-time cursor sharing without requiring CRDT guarantees for cursor ordering.
Document Snapshots and Versioning
The document model must also support snapshots and versioning. A snapshot is a complete representation of the document state at a specific point in time, while a version is a pointer to a specific state in the CRDT's history. Snapshots are used for features like revision history, restore to version X, and diff between versions. In a CRDT-based system, snapshots can be efficiently generated by encoding the full CRDT state at a specific state vector.
The server periodically creates snapshots of the document state by encoding the full CRDT state and storing it in durable storage such as S3 or PostgreSQL. The interval between snapshots depends on the write frequency: for high-traffic documents, snapshots may be created every few minutes, while for low-traffic documents, daily snapshots may suffice. Between snapshots, the server stores the operation log of individual CRDT operations, which can be replayed to reconstruct the document state at any point in time.
Handling Structural Changes
Structural changes such as splitting a paragraph, merging two paragraphs, reordering list items, or inserting a table are the most complex operations in a collaborative editor. These operations affect the document tree structure, not just the text content, and they must be represented as CRDT operations that commute with concurrent operations.
In a tree-based document model, structural operations are represented as insertions and deletions of block nodes in the block list. When two users concurrently split a paragraph at different points, the CRDT merge must produce a consistent result typically by creating two separate paragraphs, each containing the content that each user intended. This requires careful design of the block-level CRDT to handle concurrent structural modifications correctly.
The Yjs framework handles structural changes through its array and map types. A paragraph split is represented as a deletion of the original block and an insertion of two new blocks. Because the YArray CRDT deterministically resolves concurrent insertions using timestamps and replica IDs, concurrent paragraph splits at different positions produce a consistent result. However, concurrent splits at the exact same position may produce unexpected results, which is why some editors implement additional application-level logic to handle these edge cases.
8. Cursor Presence and Awareness Protocol
Cursor presence, the ability to see other users' cursors, selections, and typing indicators in real-time, is one of the defining features of collaborative editing. It transforms a shared document from a take turns editing experience into a truly collaborative one. The awareness protocol manages this ephemeral state, which is fundamentally different from the persistent document state managed by CRDTs.
Awareness Protocol Design
The awareness protocol manages three types of ephemeral state: cursor position indicating which block and character position the user's cursor is at, selection range indicating the start and end of the user's current selection, and user presence including name, avatar, color, online status, and typing indicator. This state is inherently ephemeral: it should not be persisted, it should expire if the user disconnects, and it should not conflict with other users' awareness state.
Because awareness state is ephemeral and user-specific, it does not require CRDT guarantees. Each user's awareness state is independent, user A's cursor position does not conflict with user B's cursor position, so a simple last-writer-wins approach is sufficient. The awareness protocol broadcasts each user's state to all other users, and each user locally maintains the latest state for each connected user. When a user disconnects, their awareness state is removed after a timeout.
Cursor Rendering and Overlap
Rendering multiple cursors on the same document requires careful UI design. Each user's cursor is displayed with a unique color assigned by the server or chosen by the user and a label showing the user's name. When multiple users have their cursors at the same position, the cursors are stacked vertically with a small offset so that all cursors are visible.
Selection ranges are rendered as colored highlights behind the text, with each user's selection in their unique color. Overlapping selections create blended colors to ensure that all selections are visible even when they overlap. The rendering engine must handle the following edge cases: multiple cursors at the same position, overlapping selections, cursors at the beginning or end of the document, and cursors in hidden or collapsed sections.
| Presence Feature | Update Frequency | Broadcast Scope | TTL | Persistence |
|---|---|---|---|---|
| Cursor position | Every 50-100ms (throttled) | All users in document | 30s without update | Not persisted |
| Selection range | On selection change | All users in document | 30s without update | Not persisted |
| User name/avatar | On connect | All users in document | Until disconnect + 30s | Not persisted |
| Typing indicator | On keystroke (debounced) | All users in document | 5s without keystroke | Not persisted |
| Online status | On connect/disconnect | All users in document | Until disconnect | Not persisted |
| Scroll position | On scroll (throttled) | All users in document | 30s without update | Not persisted |
C#
public class AwarenessState
{
public string UserId { get; set; }
public string UserName { get; set; }
public string UserColor { get; set; }
public string? AvatarUrl { get; set; }
public CursorPosition? Cursor { get; set; }
public SelectionRange? Selection { get; set; }
public bool IsTyping { get; set; }
public DateTime LastUpdated { get; set; }
}
public class AwarenessService
{
private readonly ConcurrentDictionary<string,
ConcurrentDictionary<string, AwarenessState>>
_documentAwareness = new();
public void UpdateAwareness(
string documentId, string userId, AwarenessState state)
{
state.LastUpdated = DateTime.UtcNow;
var docAwareness = _documentAwareness.GetOrAdd(
documentId, _ => new ConcurrentDictionary<string, AwarenessState>());
docAwareness[userId] = state;
}
public void RemoveUser(string documentId, string userId)
{
if (_documentAwareness.TryGetValue(documentId, out var docAwareness))
{
docAwareness.TryRemove(userId, out _);
if (docAwareness.IsEmpty)
_documentAwareness.TryRemove(documentId, out _);
}
}
public List<AwarenessState> GetActiveUsers(string documentId)
{
if (!_documentAwareness.TryGetValue(documentId, out var docAwareness))
return new List<AwarenessState>();
var cutoff = DateTime.UtcNow.AddSeconds(-30);
return docAwareness.Values
.Where(s => s.LastUpdated > cutoff).ToList();
}
public List<AwarenessState> GetStaleUsers(string documentId)
{
if (!_documentAwareness.TryGetValue(documentId, out var docAwareness))
return new List<AwarenessState>();
var cutoff = DateTime.UtcNow.AddSeconds(-30);
return docAwareness.Values
.Where(s => s.LastUpdated <= cutoff).ToList();
}
public byte[] EncodeAwarenessState(string documentId)
{
var users = GetActiveUsers(documentId);
return JsonSerializer.SerializeToUtf8Bytes(users);
}
}
Awareness State Encoding and Bandwidth
Awareness updates are the most frequent messages in a collaboration system: a user's cursor position can change hundreds of times per second during active editing. To minimize bandwidth, awareness updates are typically throttled at most one update per 50-100ms per user and compressed using delta encoding or binary serialization. In a document with 10 concurrent users, the awareness traffic is approximately 10 times 20 updates per second times 200 bytes = 40 KB/s, which is manageable for most network connections but should be monitored for performance.
The Yjs awareness protocol provides a production-quality implementation of cursor presence. It uses a simple JSON-based encoding for awareness states and provides built-in timeout handling for stale users. The awareness state is not part of the CRDT document state: it is a separate layer that is broadcast alongside CRDT operations but not persisted or merged. This separation of concerns with persistent document state via CRDTs and ephemeral presence state via awareness protocol is a key architectural pattern in collaboration engines.
9. Conflict Resolution Strategies
Conflict resolution is the core problem that a collaboration engine must solve. When two users make concurrent edits to the same part of a document, the system must determine a consistent outcome that all users will see. In a CRDT-based system, conflict resolution is embedded in the data structure itself: the CRDT's merge function defines how concurrent operations are resolved. However, there are different strategies for different types of conflicts, and the choice of strategy affects the user experience.
Types of Conflicts
In a collaborative editing system, conflicts can be categorized into several types:
- Text insertion conflicts: Two users insert text at the same position simultaneously. The CRDT must determine the ordering of the inserted characters.
- Text deletion conflicts: Two users delete the same text simultaneously, or one user deletes text that another user is trying to edit.
- Formatting conflicts: Two users apply different formatting to the same text range, for example one makes it bold and the other italicizes it.
- Structural conflicts: Two users make concurrent structural changes such as both splitting a paragraph, or one reorders a list while the other edits its content.
- Deletion vs. edit conflicts: One user deletes a paragraph while another user is editing it.
Each type of conflict requires a different resolution strategy. CRDTs handle text insertion conflicts elegantly through their merge algorithms. Text deletion conflicts are handled through tombstones which mark deleted content as logically deleted while retaining it in the data structure for merge purposes. Formatting conflicts require application-level logic because CRDTs alone cannot determine the correct formatting when two users apply different formats concurrently.
Text Insertion Ordering
When two users insert text at the same position simultaneously, the CRDT must determine a total ordering of the inserted characters. Different CRDT algorithms use different ordering rules. RGA uses timestamp and replica ID where the character with the larger timestamp comes first and in case of a tie, the character with the smaller replica ID comes first. YATA used by Yjs uses a favor left rule where when two characters have the same left origin, the character that was created first with smaller timestamp comes first. LSEQ uses an exponential tree structure to assign positions between existing positions, avoiding ties by construction.
All of these ordering rules produce deterministic results that all replicas will agree on, regardless of the order in which they receive the operations. The user experience differs slightly between algorithms: for example, RGA tends to place later insertions to the left, while YATA tends to place them to the right, but all are valid collaborative editing behaviors.
Formatting Conflict Resolution
Formatting conflicts require application-level logic because the CRDT cannot determine the correct formatting when two users apply different formats concurrently. Common strategies include: (1) Union where when two users apply different formatting attributes to the same range, the result includes both attributes, for example if user A makes text bold and user B italicizes it, the result is bold plus italic; (2) Last-writer-wins per attribute where for each formatting attribute independently, the last write wins; and (3) User priority where some systems allow document owners or admins to have higher priority for formatting conflicts.
The Yjs framework handles formatting through attribute encoding, where each character position can have a set of formatting attributes. When two users apply different formatting to overlapping ranges, Yjs merges the attributes using a union strategy, preserving both users' formatting choices. This approach works well for most use cases and avoids data loss from concurrent formatting operations.
Intention Preservation
A key principle in conflict resolution is intention preservation: the system should produce results that match what each user intended, as closely as possible. For text insertions, this means that each user's inserted text should appear in the document, in a position that is consistent with where they typed it. For deletions, this means that deleted text should be removed from the document. For formatting, this means that the formatting should be applied to the text the user selected.
CRDTs naturally preserve intentions for text insertions and deletions because they preserve all operations using tombstones for deletions and merge them deterministically. For formatting, intention preservation is more nuanced and requires careful design of the formatting model. The key insight is that the formatting model must be compositional: the result of merging two formatting operations should be the union of both operations' intentions, not the loss of one of them.
C#
public class ConflictResolver
{
public FormattingState ResolveFormattingConflict(
FormattingState localFormatting,
FormattingState remoteFormatting,
int position, int length)
{
var result = new FormattingState();
var allKeys = localFormatting.Attributes.Keys
.Union(remoteFormatting.Attributes.Keys);
foreach (var key in allKeys)
{
var localValue = localFormatting.Attributes.GetValueOrDefault(key);
var remoteValue = remoteFormatting.Attributes.GetValueOrDefault(key);
if (localValue == null) result.Attributes[key] = remoteValue;
else if (remoteValue == null) result.Attributes[key] = localValue;
else result.Attributes[key] = MergeAttributeValues(key, localValue, remoteValue);
}
return result;
}
private object MergeAttributeValues(string attributeName, object local, object remote)
{
switch (attributeName)
{
case "bold": case "italic": case "underline":
return (bool)local || (bool)remote;
case "color": case "backgroundColor":
return remote;
case "fontSize":
return remote;
case "className":
var localClasses = local.ToString().Split(' ').ToHashSet();
var remoteClasses = remote.ToString().Split(' ').ToHashSet();
return string.Join(" ", localClasses.Union(remoteClasses));
default:
return remote;
}
}
}
This conflict resolution implementation demonstrates how application-level logic complements CRDT merge algorithms. While the CRDT handles low-level text ordering and tombstoning, the conflict resolver handles higher-level semantics like formatting attribute merging. The union strategy for boolean attributes including bold and italic ensures that both users' formatting choices are preserved, while the last-writer-wins strategy for visual attributes including color and font size prevents jarring mixed-formatting artifacts.
User-Visible Conflict Indicators
In some collaborative editing systems, conflicts are surfaced to users as visual indicators. For example, when two users make concurrent edits to the same paragraph, the system might highlight the conflicting region with a colored background, showing each user's version. This approach, used by tools like Google Docs Suggesting mode, helps users understand what changes were made by others and allows them to manually resolve conflicts that the automated system cannot handle.
For CRDT-based systems, conflict indicators are optional because the CRDT merge automatically resolves all conflicts. However, they can be useful for structural conflicts such as two users moving a list item to different positions where the automated merge result may not match either user's intention. In these cases, the system can present the conflict to the user and ask them to choose the desired outcome.
10. Server-Side CRDT Aggregation and Persistence
In a server-aided CRDT architecture, the server plays a critical role as the aggregator, persistence layer, and coordinator. While CRDTs are designed to work without coordination, a server provides significant practical benefits including reliable operation delivery, durable persistence, access control, garbage collection, and initial state distribution. This section examines the server-side architecture for CRDT aggregation and persistence.
Server-Side State Management
The server maintains an authoritative CRDT state for each document. This state is the result of merging all operations received from all clients. When a client sends an operation, the server merges it into the authoritative state, persists the operation to the operation log, and broadcasts the operation to other clients. The authoritative state serves as the source of truth for new clients connecting for the first time and for clients that need to resynchronize after a long disconnection.
Operation Log and Durability
The operation log is the primary durability mechanism in the collaboration engine. Every operation received from a client is appended to the log before it is merged into the authoritative state. The log provides several guarantees: (1) operations are never lost, even if the server crashes; (2) operations can be replayed to reconstruct the document state at any point in time; and (3) the log provides an audit trail for debugging and compliance.
The operation log can be implemented using several storage backends. For high-throughput documents, a write-ahead log in append-only storage like S3 or Apache Kafka provides excellent write performance. For lower-throughput documents, appending to a PostgreSQL table with a monotonically increasing sequence number is simpler and sufficient. The log entries include the operation payload as serialized CRDT operation, the client ID, the server-assigned timestamp, and the version vector at the time of receipt.
| Storage Component | Data Stored | Retention | Access Pattern | Optimization |
|---|---|---|---|---|
| Operation Log (S3) | Individual CRDT operations | 90 days then archived | Sequential write, rare read | Batch writes, compression |
| Document Snapshot (PostgreSQL) | Full CRDT state encoded | Indefinite | Read on reconnect, periodic write | Binary column, index on doc ID |
| Authoritative State (Memory) | Current CRDT state in-memory | While document is active | Read/write on every operation | Lazy loading, LRU eviction |
| Delta Buffer (Memory) | Unsynced operations per client | Until client acknowledges | Write on operation, read on ack | Bounded buffer size |
Document Loading and Eviction
Not all documents can be kept in memory simultaneously. The server must implement a document loading and eviction strategy that balances memory usage with latency. When a client requests a document that is not currently in memory, the server loads it from the most recent snapshot stored in PostgreSQL and replays any operations from the operation log that occurred after the snapshot. This replay process reconstructs the current authoritative state.
Documents that have not been accessed for a configurable period such as 30 minutes are evicted from memory. Before eviction, the server ensures that the latest state has been persisted to the snapshot store. When a client subsequently accesses the evicted document, the server reloads it from the snapshot store and begins serving operations again. This lazy loading approach ensures that memory is used efficiently while maintaining acceptable latency for active documents.
C#
public class DocumentManager
{
private readonly ConcurrentDictionary<string, LoadedDocument> _loadedDocuments = new();
private readonly IDocumentSnapshotStore _snapshotStore;
private readonly IOperationLogStore _operationLog;
private readonly TimeSpan _evictionTimeout = TimeSpan.FromMinutes(30);
public DocumentManager(IDocumentSnapshotStore snapshotStore, IOperationLogStore operationLog)
{
_snapshotStore = snapshotStore;
_operationLog = operationLog;
}
public async Task<LoadedDocument> GetDocumentAsync(string documentId)
{
if (_loadedDocuments.TryGetValue(documentId, out var loaded))
{
loaded.LastAccessed = DateTime.UtcNow;
return loaded;
}
return await LoadDocumentAsync(documentId);
}
private async Task<LoadedDocument> LoadDocumentAsync(string documentId)
{
var snapshot = await _snapshotStore.GetLatestSnapshotAsync(documentId);
var crdtState = snapshot != null
? DeserializeCrdtState(snapshot.StateData)
: new CollaborativeDocument(documentId);
var operations = await _operationLog.GetOperationsSinceAsync(
documentId, snapshot?.VersionVector);
foreach (var op in operations) crdtState.ApplyDelta(op.Payload);
var loaded = new LoadedDocument
{
DocumentId = documentId, CrdtState = crdtState,
LastAccessed = DateTime.UtcNow, LoadedAt = DateTime.UtcNow
};
_loadedDocuments[documentId] = loaded;
return loaded;
}
public void EvictStaleDocuments()
{
var cutoff = DateTime.UtcNow - _evictionTimeout;
var staleDocs = _loadedDocuments
.Where(kvp => kvp.Value.LastAccessed < cutoff)
.Select(kvp => kvp.Key).ToList();
foreach (var docId in staleDocs)
{
if (_loadedDocuments.TryRemove(docId, out var doc))
_ = PersistDocumentAsync(doc);
}
}
private async Task PersistDocumentAsync(LoadedDocument doc)
{
var encoded = doc.CrdtState.YjsDocument.EncodeStateAsUpdate();
await _snapshotStore.SaveSnapshotAsync(doc.DocumentId,
new DocumentSnapshot
{
StateData = encoded,
VersionVector = doc.CrdtState.GetStateVector(),
CreatedAt = DateTime.UtcNow
});
}
}
This implementation demonstrates the document loading and eviction lifecycle. Documents are lazily loaded from snapshots and operation logs, kept in memory while active, and evicted with persistence after a period of inactivity.
Garbage Collection of Tombstones
Over time, CRDT data structures accumulate tombstones (markers for deleted content) that consume memory without providing value. In a text editing CRDT, every deleted character leaves a tombstone in the data structure. For a document that has been heavily edited, tombstones can consume significantly more memory than the actual content. Periodic garbage collection removes tombstones that are no longer needed.
A tombstone is safe to remove when all replicas have observed the deletion: when the deletion operation's timestamp is included in every connected client's version vector. The server tracks the minimum version vector across all connected clients, the checkpoint version, and garbage-collects tombstones that are older than this checkpoint. When a client reconnects with an older version vector, the server must include the full state, not a delta, because the delta may reference tombstones that have been garbage-collected.
11. Offline Support and Sync Reconciliation
One of the primary advantages of CRDTs over OT is their native support for offline editing. Because CRDTs guarantee convergence without coordination, a client can continue editing while disconnected and seamlessly sync its changes when connectivity is restored. This section examines the design of the offline support system, including local storage, operation queuing, and sync reconciliation.
Offline Architecture
When a client goes offline, it continues to apply operations to its local CRDT state, which is also persisted to the browser's IndexedDB or equivalent local storage. The client queues all outbound operations in a local operation log. When connectivity is restored, the client enters the sync reconciliation phase: it sends its version vector to the server, receives any operations it has missed, and merges them into its local state. The server also receives the client's queued operations and merges them into the authoritative state.
Local Storage Design
The client's local storage must persist three things: (1) the current CRDT state, (2) the local operation log containing operations generated while offline, and (3) the awareness state including cursor positions and user info. IndexedDB is the standard choice for browser-based clients, providing sufficient storage capacity and transactional semantics. The CRDT state is stored as a binary blob of the encoded Yjs document, and the operation log is stored as a sorted list of operations with their timestamps.
| Local Storage Component | Format | Purpose | Lifecycle |
|---|---|---|---|
| CRDT State | Binary blob (Yjs encoded) | Full document state for offline editing | Updated on every operation, synced on reconnect |
| Operation Log | JSON array of operations | Queued operations for sync on reconnect | Appended during offline, cleared after successful sync |
| Version Vector | JSON object (replica to counter) | Tracks which operations have been observed | Updated on every operation |
| User Session | JSON (userId, token, documentId) | Session info for quick reconnection | Stored on connect, cleared on explicit logout |
| Awareness State | JSON (cursor, selection, name) | Ephemeral presence state | Not persisted, regenerated on reconnect |
Sync Reconciliation Algorithm
The sync reconciliation algorithm is the most critical component of the offline support system. It must handle several scenarios: (1) the client was offline for a short period and missed a few operations; (2) the client was offline for a long period and missed many operations; (3) the client has operations that the server has not seen because the client was offline when it generated them; and (4) both the client and the server have new operations because the client was editing offline while other users were editing online.
The reconciliation algorithm works as follows: the client sends its version vector to the server. The server compares this with the authoritative version vector and determines which operations the client has missed. If the number of missed operations is small below a configurable threshold, the server sends the individual operations as a delta. If the number is large suggesting a long disconnection, the server sends the full CRDT state as a snapshot. The client then merges the received data into its local state using the CRDT merge function, which deterministically resolves any conflicts between the client's offline edits and the server's operations.
C#
public class SyncReconciliationService
{
private readonly IDocumentManager _documentManager;
private readonly ISnapshotStore _snapshotStore;
private readonly ILogger<SyncReconciliationService> _logger;
private const int DELTA_THRESHOLD = 1000;
public async Task<SyncResult> ReconcileAsync(
string documentId, byte[] clientStateVector, byte[]? clientOperations)
{
var document = await _documentManager.GetDocumentAsync(documentId);
var serverStateVector = document.CrdtState.GetStateVector();
if (clientOperations != null && clientOperations.Length > 0)
document.CrdtState.ApplyDelta(clientOperations);
var diff = ComputeDiff(clientStateVector, serverStateVector);
if (diff.OperationCount > DELTA_THRESHOLD)
{
_logger.LogInformation(
"Large sync for {DocId}: {Count} ops, sending full state",
documentId, diff.OperationCount);
var fullState = document.CrdtState.EncodeFullState();
return new SyncResult
{
Type = SyncResultType.FullState,
Payload = fullState,
ServerStateVector = serverStateVector
};
}
var delta = document.CrdtState.GetDelta(clientStateVector);
return new SyncResult
{
Type = SyncResultType.Delta,
Payload = delta,
ServerStateVector = serverStateVector
};
}
private StateDiff ComputeDiff(byte[] clientSV, byte[] serverSV)
{
var clientMap = DeserializeStateVector(clientSV);
var serverMap = DeserializeStateVector(serverSV);
int operationCount = 0;
foreach (var kvp in serverMap)
{
var clientCount = clientMap.GetValueOrDefault(kvp.Key, 0);
operationCount += (int)(kvp.Value - clientCount);
}
return new StateDiff { OperationCount = operationCount };
}
}
This implementation demonstrates the core reconciliation logic: compare state vectors, determine the diff size, and either send a delta for small diffs or a full state for large diffs. The threshold of 1000 operations is a tunable parameter that balances bandwidth efficiency with latency.
Conflict-Free Offline Editing
The beauty of CRDT-based offline editing is that no conflict resolution is needed during the sync process. The CRDT merge function automatically handles any conflicts between the client's offline edits and the server's operations. This is fundamentally different from OT-based systems, where offline sync requires complex operational transformation against the server's operation history.
However, there are practical considerations that must be addressed. First, the client's local storage must be large enough to hold the full CRDT state and the offline operation log. For a large document with 100,000 characters with rich formatting, the CRDT state can be several megabytes. IndexedDB provides sufficient storage for most use cases, but applications must handle storage quota errors gracefully. Second, the sync process must be idempotent: if the client reconnects and syncs but the connection drops during the sync, the client should be able to retry the sync without duplicating operations.
12. Version Vectors and Causality Tracking
Version vectors are the fundamental mechanism for tracking causality in a distributed system. In a CRDT-based collaboration engine, version vectors serve multiple purposes: determining which operations a client has missed for efficient sync, detecting concurrent operations for conflict resolution, and tracking the age of tombstones for garbage collection. This section examines the design and implementation of version vectors in a collaboration engine.
Version Vector Basics
A version vector is a vector of counters, one per replica (client or server) in the system. Each counter tracks the number of operations that have been observed from the corresponding replica. When a replica receives a new operation, it increments the counter for the operation's source replica. Version vectors support three key comparisons: (1) Equality where two version vectors are equal if all their counters are equal, meaning the replicas have observed exactly the same set of operations; (2) Partial order or happens-before where version vector A is less than or equal to version vector B if every counter in A is less than or equal to the corresponding counter in B, meaning all operations observed by A have also been observed by B; and (3) Concurrency where if neither A is less than or equal to B nor B is less than or equal to A, then A and B are concurrent, meaning each replica has observed some operations that the other has not.
These comparisons enable the collaboration engine to efficiently determine the relationship between any two replicas' states. When a client connects for sync, the server compares the client's version vector with the authoritative version vector. If the client's vector is less than or equal to the server's, the server sends the missing operations. If the vectors are concurrent, which can happen if the server received operations from other clients while this client was offline, the server sends both the missing operations and the client's operations are merged into the authoritative state.
Hybrid Logical Clocks
Version vectors track causal ordering but do not provide a total ordering of events. For some use cases such as LWW-Registers (last-writer-wins) and operation log ordering, a total ordering is needed. Hybrid Logical Clocks (HLCs) provide this total ordering while remaining consistent with causal ordering. An HLC combines a physical clock component for wall-clock ordering of causally unrelated events with a logical clock component for causally related events.
An HLC timestamp is a tuple of physical time, logical counter, and replica ID. When a replica generates a new event, it sets the physical time to the maximum of its current physical clock and the physical time of the last event it observed. The logical counter is incremented if the physical time has not advanced, ensuring that events with the same physical time are ordered by logical counter and replica ID. This guarantees that if event A causally precedes event B, then HLC(A) is less than HLC(B).
C#
public class HybridLogicalClock
{
private long _physicalTime;
private long _logicalCounter;
private readonly string _replicaId;
public HybridLogicalClock(string replicaId)
{
_replicaId = replicaId;
_physicalTime = GetCurrentPhysicalTime();
_logicalCounter = 0;
}
public HlcTimestamp Now()
{
var currentPhysical = GetCurrentPhysicalTime();
if (currentPhysical > _physicalTime)
{
_physicalTime = currentPhysical;
_logicalCounter = 0;
}
else _logicalCounter++;
return new HlcTimestamp(_physicalTime, _logicalCounter, _replicaId);
}
public HlcTimestamp Receive(HlcTimestamp incoming)
{
var currentPhysical = GetCurrentPhysicalTime();
if (currentPhysical > _physicalTime && currentPhysical > incoming.PhysicalTime)
{
_physicalTime = currentPhysical;
_logicalCounter = 0;
}
else if (incoming.PhysicalTime > _physicalTime)
{
_physicalTime = incoming.PhysicalTime;
_logicalCounter = incoming.LogicalCounter + 1;
}
else if (_physicalTime == incoming.PhysicalTime)
{
_logicalCounter = Math.Max(_logicalCounter, incoming.LogicalCounter) + 1;
}
else _logicalCounter++;
return new HlcTimestamp(_physicalTime, _logicalCounter, _replicaId);
}
private long GetCurrentPhysicalTime() =>
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
}
public record HlcTimestamp(
long PhysicalTime, long LogicalCounter, string ReplicaId) : IComparable<HlcTimestamp>
{
public int CompareTo(HlcTimestamp? other)
{
if (other == null) return 1;
int cmp = PhysicalTime.CompareTo(other.PhysicalTime);
if (cmp != 0) return cmp;
cmp = LogicalCounter.CompareTo(other.LogicalCounter);
if (cmp != 0) return cmp;
return string.Compare(ReplicaId, other.ReplicaId, StringComparison.Ordinal);
}
}
This implementation demonstrates a production-quality Hybrid Logical Clock. The Now() method generates a new timestamp for local events, and the Receive() method updates the clock when an event from another replica is observed. The comparison operators ensure that HLC timestamps provide a total ordering that is consistent with causal ordering.
Version Vector Implementation for Collaboration
In a collaboration engine, the version vector is extended to support the specific needs of CRDT synchronization. Each entry in the vector maps a replica ID to the number of operations from that replica that have been observed. The vector is encoded as a compact binary format for efficient transmission over the network.
The version vector is used in three key scenarios: (1) during sync reconciliation, to determine which operations a client has missed; (2) during garbage collection, to determine which tombstones are safe to remove; and (3) during operation validation, to detect stale operations which are operations that were generated based on an outdated view of the document.
| Version Vector Operation | Time Complexity | Use Case |
|---|---|---|
| Increment (local op) | O(1) | Track new local operation |
| Update (remote op) | O(1) | Track observed remote operation |
| Merge (sync) | O(N) where N = replicas | Merge two version vectors during sync |
| Compare (less-than-or-equal) | O(N) where N = replicas | Check if one state is causally behind another |
| Diff | O(N) where N = replicas | Compute missing operations for sync |
| Encode (serialize) | O(N) where N = replicas | Network transmission |
Compact Version Vectors
Standard version vectors have a space complexity of O(N) where N is the number of replicas. In a collaboration system with many concurrent users, this can become significant. Compact version vectors use several techniques to reduce space: (1) removing entries for replicas that are known to be inactive because their operations are fully observed by all active replicas; (2) using variable-length integer encoding for counter values; and (3) using delta encoding to represent only the differences from a known base vector.
The Yjs library implements a particularly efficient version vector encoding called a state vector, which is a compact binary representation of the replica to counter mapping. The state vector is typically only a few hundred bytes, even for documents with many replicas, making it efficient to transmit during sync reconciliation.
13. Access Control and Permission Model
A collaboration engine must enforce access control at multiple levels: who can connect to a document, who can read its content, who can make edits, and who can perform administrative actions like sharing permissions or deleting the document. This section examines the access control and permission model for a CRDT-based collaboration engine.
Permission Levels
A typical collaboration engine supports three permission levels: viewer (can read the document but not edit), editor (can read and edit the document), and admin (can read, edit, and manage permissions). These permission levels are enforced at the server level, not the client level, because a malicious client could bypass client-side restrictions. The server validates every incoming operation against the client's permission level and rejects operations from clients without sufficient permissions.
| Permission Level | Read Content | Edit Content | View Cursors | Share Document | Delete Document | Manage Permissions |
|---|---|---|---|---|---|---|
| Viewer | Yes | No | Yes | No | No | No |
| Editor | Yes | Yes | Yes | No | No | No |
| Admin | Yes | Yes | Yes | Yes | Yes | Yes |
| Owner | Yes | Yes | Yes | Yes | Yes | Yes |
JWT-Based Authentication
Authentication is handled through JSON Web Tokens (JWTs). When a client connects to the WebSocket gateway, it sends a JWT that encodes the user's identity and document permissions. The gateway validates the JWT signature and extracts the user's permission level. Subsequent operations from the client are tagged with the user's identity, and the aggregation service validates each operation against the user's permission level.
JWTs are time-limited (typically with a 1-hour expiry) and must be refreshed periodically. When a JWT expires, the client must obtain a new token from the authentication service and re-authenticate the WebSocket connection. The gateway handles this gracefully by sending a token-expired control message to the client, prompting it to refresh the token and re-authenticate without disconnecting.
C#
public class OperationValidator
{
private readonly IPermissionStore _permissionStore;
private readonly ILogger<OperationValidator> _logger;
public async Task<ValidationResult> ValidateAsync(
string documentId, string userId, CrdtOperation operation)
{
var permission = await _permissionStore.GetPermissionAsync(documentId, userId);
if (permission == null)
{
_logger.LogWarning(
"User {UserId} has no permission for document {DocId}",
userId, documentId);
return ValidationResult.Denied("No access");
}
switch (operation.Type)
{
case OperationType.Insert:
case OperationType.Delete:
case OperationType.Format:
if (permission.Level < PermissionLevel.Editor)
return ValidationResult.Denied("Insufficient permissions for edit");
break;
case OperationType.Share:
case OperationType.DeleteDocument:
if (permission.Level < PermissionLevel.Admin)
return ValidationResult.Denied("Insufficient permissions for admin action");
break;
}
if (!await CheckRateLimitAsync(userId, operation))
return ValidationResult.Denied("Rate limit exceeded");
if (!ValidateOperationSyntax(operation))
return ValidationResult.Denied("Invalid operation format");
return ValidationResult.Allowed();
}
private async Task<bool> CheckRateLimitAsync(string userId, CrdtOperation operation)
{
var key = $"ratelimit:{userId}";
var currentCount = await IncrementCounterAsync(key, TimeSpan.FromMinutes(1));
return currentCount < 1000;
}
private bool ValidateOperationSyntax(CrdtOperation operation)
{
return operation.Position >= 0 &&
!string.IsNullOrEmpty(operation.ReplicaId) &&
operation.Timestamp > 0;
}
}
Rate Limiting and Abuse Prevention
Rate limiting is essential for preventing abuse and ensuring fair resource usage. The collaboration engine must limit: (1) the rate of operations per user to prevent a single user from flooding the system; (2) the rate of connections per IP to prevent connection storms; (3) the rate of document creation per user to prevent storage exhaustion; and (4) the total bandwidth per connection to prevent resource monopolization.
Rate limiting is implemented at the WebSocket gateway level, using a token bucket algorithm for smooth rate limiting. Each user has a token bucket that refills at a configured rate such as 100 operations per minute. When a user exceeds their rate limit, the gateway rejects their operations with a rate-limited error and temporarily suspends their connection. The rate limits are configurable per permission level: editors have higher rate limits than viewers, and admins have the highest rate limits.
Document Sharing and Link Access
Document sharing is managed through a permission store that maps (documentId, userId) pairs to permission levels. Users can share documents with other users by creating permission entries. For broader sharing, the system supports link-based access, where a document can be shared via a URL with a configurable permission level (viewer or editor). Link-based access is implemented through a separate permission entry that maps (documentId, shareToken) to a permission level, where the shareToken is a random string embedded in the share URL.
The permission store must be highly available because every operation validation requires a permission check. In practice, permissions are cached in Redis with a short TTL (e.g., 60 seconds) to minimize latency. When a permission is changed (e.g., a user's access is revoked), the cache entry is invalidated immediately, and subsequent operations from the revoked user are rejected.
14. Performance Optimization (Compression, Delta Sync)
Performance is critical for a collaboration engine. Users expect sub-100ms latency for their edits to be visible, and the system must handle hundreds of concurrent editors on a single document without degrading performance. This section examines the key performance optimization techniques for a CRDT-based collaboration engine, including compression, delta sync, operation batching, and server-side processing optimizations.
Delta Sync Optimization
Delta sync is the most important performance optimization in a collaboration engine. Instead of transmitting the full CRDT state on every synchronization, the system transmits only the changes (deltas) since the last synchronization. This dramatically reduces bandwidth usage, especially for large documents where a typical operation (inserting a single character) is orders of magnitude smaller than the full document state.
In a delta-state CRDT, each replica maintains a delta buffer that accumulates state changes since the last synchronization. When synchronizing with another replica, the replica sends the delta buffer contents rather than the full state. The delta buffer is encoded using a compact binary format that includes only the new nodes, updated metadata, and tombstones since the last sync. This approach typically reduces the synchronization payload by 100x or more compared to full-state sync.
Compression Techniques
CRDT operations have significant redundancy that can be exploited for compression. Character insert operations, which constitute the majority of operations in a text editing workload, contain: a replica ID (typically 8-16 bytes), a timestamp (8 bytes), a position (variable-length integer, typically 1-4 bytes), and a character value (1-4 bytes for UTF-8). The replica ID and position are highly repetitive and compress well with delta encoding and variable-length integer encoding.
Several compression techniques are applicable to CRDT operations: (1) Variable-length integer encoding (varint) for position values, which are typically small integers; (2) Delta encoding for consecutive operations from the same replica, storing only the difference from the previous operation; (3) Dictionary compression for replica IDs, replacing 16-byte UUIDs with 2-byte indices; and (4) General-purpose compression (zstd, lz4) for operation batches, which exploits cross-operation redundancy.
| Compression Technique | Applicable To | Compression Ratio | CPU Overhead | Recommendation |
|---|---|---|---|---|
| Varint encoding | Positions, lengths | 2-4x for small integers | Negligible | Always use |
| Delta encoding | Consecutive ops from same replica | 3-10x | Low | Use for operation logs |
| Dictionary compression | Replica IDs, repeated strings | 5-20x for IDs | Low | Use for network messages |
| zstd compression | Operation batches, full states | 3-8x typical | Medium | Use for storage and large transfers |
| lz4 compression | Operation batches (speed priority) | 2-4x typical | Low | Use for real-time messages |
| Columnar encoding | Full CRDT states (Automerge) | 5-15x | Medium | Use for snapshot storage |
Operation Batching
Sending every keystroke as a separate WebSocket message is inefficient because each message has fixed overhead (WebSocket frame header, HTTP/2 framing, TLS record overhead). Operation batching groups multiple operations into a single message, amortizing the per-message overhead across multiple operations. The batch size is typically controlled by a time window (e.g., send every 16ms, matching the 60fps render cycle) rather than a count threshold, because the time-based approach adapts to the user's typing speed automatically.
Batching must be implemented carefully to avoid introducing latency. The client should not wait for the batch timer to fire before applying operations locally; operations should be applied to the local CRDT state immediately for responsiveness, and the batch timer should only control when the operations are transmitted to the server. When the batch timer fires, all pending operations are serialized into a single message, compressed, and sent via the WebSocket connection.
Server-Side Processing Optimizations
The server-side aggregation pipeline must be optimized for throughput and latency. Key optimizations include: (1) Asynchronous processing where operation validation, persistence, and broadcast are performed asynchronously without blocking the receive loop; (2) Batch persistence where operations are accumulated in a write buffer and flushed to storage periodically, reducing I/O overhead; (3) Fan-out optimization where the server maintains a list of connected clients per document and sends operations directly to each client without broadcasting through a message queue; and (4) Connection-aware routing where operations are routed to clients through the closest server instance using consistent hashing on the document ID.
Memory Management
CRDT data structures consume memory that grows with the number of operations and the number of replicas. Memory management is critical for preventing out-of-memory conditions in long-running servers. Key techniques include: (1) Tombstone garbage collection as discussed in the server aggregation section; (2) State compaction where the CRDT state is periodically compressed by removing internal metadata that is no longer needed; (3) Lazy loading where documents are loaded into memory only when actively being edited; and (4) LRU eviction where inactive documents are evicted from memory and reloaded from snapshots when needed.
15. Multi-Document Workspace Architecture
Most collaboration platforms manage multiple documents organized into workspaces, folders, or projects. The multi-document workspace architecture must support efficient document discovery, hierarchical organization, cross-document references, and workspace-level operations such as search and bulk permissions. This section examines the architecture for managing multiple collaborative documents within a workspace.
Workspace Data Model
A workspace is a container for documents and sub-workspaces. Each workspace has metadata (name, description, owner, creation date), permissions (who can access the workspace and its documents), and a tree structure of documents and sub-workspaces. The workspace metadata is stored in a relational database (PostgreSQL) for efficient querying, while the document content is stored in the CRDT-based storage layer.
The workspace data model must support several access patterns: listing documents in a workspace, searching for documents by name or content, viewing document metadata (last modified, active users), and managing workspace permissions. These operations are read-heavy and benefit from database indexes and caching. The workspace metadata is cached in Redis with a 5-minute TTL, and document metadata is updated on every operation.
Document Discovery and Navigation
Users need to navigate between documents efficiently. The workspace UI provides a sidebar with a tree view of documents and folders, a search bar for finding documents by name or content, and a recent documents list. The document list is powered by a database query that returns documents matching the user's permissions, sorted by last modified date. The search functionality uses a full-text search engine (Elasticsearch or PostgreSQL full-text search) that indexes document titles and content.
| Workspace Feature | Storage Backend | Access Pattern | Caching Strategy |
|---|---|---|---|
| Document list | PostgreSQL with indexes | Read-heavy, paginated | Redis cache with 5min TTL |
| Document metadata | PostgreSQL + Redis | Read on every open, write on every edit | Redis cache with 1min TTL |
| Workspace permissions | PostgreSQL + Redis | Read on every operation, write on permission change | Redis cache with 60s TTL, immediate invalidation |
| Full-text search | Elasticsearch or PostgreSQL FTS | Read on search query, write on document change | Index updated async on document change |
| Active users per document | Redis with presence keys | Read on document list, write on connect/disconnect | Direct Redis read, no caching needed |
| Recent documents | Redis sorted set per user | Read on workspace open, write on document access | Direct Redis read, no caching needed |
Cross-Document Operations
Some collaboration features require coordination across multiple documents: workspace-level search, bulk permission changes, document move and copy operations, and cross-document references. These operations are handled by a workspace service that coordinates across multiple document aggregation services.
Document move and copy operations are particularly complex in a CRDT-based system. When a document is moved from one workspace to another, the server must update the document's metadata (parent workspace ID) while preserving the CRDT state and operation history. The move operation is atomic: either the document is fully moved or it remains in its original location. Copy operations create a new CRDT state that is a clone of the source document's state, with a new document ID and operation history.
Workspace-Level Aggregation
Workspace-level features such as analytics, audit logs, and compliance reporting require aggregating data across multiple documents. The workspace analytics service periodically computes metrics such as total documents, active users, storage usage, and edit frequency. These metrics are stored in a time-series database (InfluxDB or Prometheus) for efficient querying and visualization.
Audit logging captures all significant events in the workspace: document creation, deletion, permission changes, and bulk operations. The audit log is stored in an append-only log (Kafka or S3) for compliance and debugging. The audit log is searchable through a separate indexing pipeline that feeds into Elasticsearch for efficient querying.
C#
public class WorkspaceService
{
private readonly IDocumentRepository _documentRepo;
private readonly IWorkspaceRepository _workspaceRepo;
private readonly IPermissionService _permissionService;
private readonly ISearchIndex _searchIndex;
public async Task<WorkspaceSummary> GetWorkspaceSummaryAsync(
string workspaceId, string userId)
{
var workspace = await _workspaceRepo.GetAsync(workspaceId);
if (!await _permissionService.CanViewAsync(workspaceId, userId))
throw new UnauthorizedAccessException("No access to workspace");
var documents = await _documentRepo.ListByWorkspaceAsync(
workspaceId, pageSize: 50, offset: 0);
var activeUsers = await GetActiveUsersAsync(workspaceId);
return new WorkspaceSummary
{
Workspace = workspace,
RecentDocuments = documents,
ActiveUserCount = activeUsers.Count,
TotalDocumentCount = await _documentRepo.CountByWorkspaceAsync(workspaceId),
StorageUsedBytes = await _documentRepo.GetStorageUsageAsync(workspaceId)
};
}
public async Task<SearchResult> SearchDocumentsAsync(
string workspaceId, string query, string userId)
{
if (!await _permissionService.CanViewAsync(workspaceId, userId))
throw new UnauthorizedAccessException("No access to workspace");
var results = await _searchIndex.SearchAsync(workspaceId, query);
return new SearchResult
{
Query = query,
Matches = results.Matches,
TotalCount = results.TotalCount
};
}
public async Task MoveDocumentAsync(
string documentId, string targetWorkspaceId, string userId)
{
var document = await _documentRepo.GetAsync(documentId);
if (!await _permissionService.CanEditAsync(document.WorkspaceId, userId))
throw new UnauthorizedAccessException("No edit access");
if (!await _permissionService.CanEditAsync(targetWorkspaceId, userId))
throw new UnauthorizedAccessException("No edit access to target");
document.WorkspaceId = targetWorkspaceId;
await _documentRepo.UpdateAsync(document);
}
}
This workspace service implementation demonstrates the core operations for managing a multi-document workspace: listing documents with summaries, searching across documents, and moving documents between workspaces. Each operation checks permissions before performing the action, ensuring that users can only access documents and workspaces they are authorized to use.
Scaling the Workspace Architecture
As the number of documents and users grows, the workspace architecture must scale horizontally. Document storage is sharded by document ID, with each shard managed by a separate aggregation service instance. Workspace metadata is partitioned by workspace ID, with each partition managed by a separate database shard. The search index is distributed across multiple nodes using Elasticsearch's built-in sharding. The presence service uses Redis cluster mode for horizontal scaling of ephemeral state.
16. Undo/Redo in Collaborative Environments
Undo and redo are expected features in any text editor, but they become significantly more complex in a collaborative environment. In a single-user editor, undo simply reverts the last operation. In a collaborative editor, undo must handle concurrent operations from other users, maintain the user's intent (e.g., undoing only this user's changes, not other users' changes), and avoid breaking the CRDT convergence guarantees. This section examines the design of undo/redo in a CRDT-based collaboration engine.
The Undo Problem in Collaborative Editing
In a collaborative editor, undo is not simply the inverse of the last operation. Consider this scenario: user A types hello, then user B types world at the end of the document, then user A presses undo. If undo simply reverts A's last character, it would delete the o from hello, resulting in hellworld. But if A intended to undo their entire word hello, undo should remove hello while preserving world. The correct behavior depends on the undo policy: character-level undo (revert the last character) or semantic undo (revert the last meaningful action, such as a word or paragraph).
Additionally, undo in a collaborative editor should only undo the current user's changes, not other users' changes. If user A types hello, user B types world, and then user A presses undo, only A's changes should be reverted. B's changes should remain. This is called user-scoped undo, and it requires tracking which operations were generated by which user.
Undo Stack Design
The undo stack is a data structure that tracks the operations that can be undone by the current user. Each entry in the stack represents a single undoable action, such as typing a word, applying formatting, or deleting a paragraph. When the user performs an action, the corresponding operations are pushed onto the undo stack. When the user presses undo, the top entry is popped from the stack, and the inverse operations are applied to the document.
In a CRDT-based system, the undo stack must be carefully designed to maintain convergence. The inverse operations must be valid CRDT operations that commute with concurrent operations from other users. The standard approach is to use tombstone-based undo: instead of deleting the characters that were inserted, the undo operation inserts tombstones at the appropriate positions. This preserves the monotonicity property of the CRDT while achieving the user-visible effect of removing the content.
| Undo Policy | Granularity | User Intent | Implementation | Complexity |
|---|---|---|---|---|
| Character-level | Single character | Exact reversal of last keystroke | Inverse CRDT operation | Low |
| Word-level | Word or whitespace sequence | Revert last typed word | Group consecutive insert operations | Medium |
| Action-level | UI action (typing, formatting, paste) | Revert last user action | Action grouping with timestamps | Medium-High |
| User-scoped | Current user's operations only | Revert only my changes | Per-user operation tracking | High |
| Tree-based | Hierarchical undo tree | Undo with branching history | DAG of operation groups | Very High |
CRDT-Compatible Undo Implementation
The key challenge for undo in CRDTs is maintaining the monotonicity property. A naive undo would remove characters from the CRDT, which violates monotonicity because removing a character requires changing the state in a non-upward direction. The solution is to use tombstones: an undo operation inserts a tombstone at the position of each character that should be removed. The tombstone marks the character as deleted without physically removing it from the data structure. This preserves monotonicity while achieving the user-visible effect of undoing the operation.
For redo, the process is reversed: the tombstones that were inserted by the undo operation are removed, restoring the original characters. In practice, redo is implemented by maintaining a separate redo stack that stores the undo operations. When the user presses redo, the top entry of the redo stack is popped and applied as a new operation.
C#
public class CollaborativeUndoManager
{
private readonly Stack<UndoEntry> _undoStack = new();
private readonly Stack<UndoEntry> _redoStack = new();
private readonly string _userId;
private readonly CollaborativeDocument _document;
public CollaborativeUndoManager(string userId, CollaborativeDocument document)
{
_userId = userId;
_document = document;
}
public void TrackOperation(CrdtOperation operation)
{
if (operation.UserId != _userId) return;
if (_undoStack.Count > 0 && _undoStack.Peek().CanMergeWith(operation))
{
_undoStack.Peek().Operations.Add(operation);
}
else
{
_undoStack.Push(new UndoEntry
{
Operations = new List<CrdtOperation> { operation },
Timestamp = DateTimeOffset.UtcNow
});
}
_redoStack.Clear();
}
public void Undo()
{
if (_undoStack.Count == 0) return;
var entry = _undoStack.Pop();
var inverseOps = new List<CrdtOperation>();
foreach (var op in entry.Operations)
{
switch (op.Type)
{
case OperationType.Insert:
inverseOps.Add(new CrdtOperation
{
Type = OperationType.Delete,
Position = op.Position,
Value = op.Value,
UserId = _userId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
break;
case OperationType.Delete:
inverseOps.Add(new CrdtOperation
{
Type = OperationType.Insert,
Position = op.Position,
Value = op.Value,
UserId = _userId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
break;
}
}
foreach (var inverseOp in inverseOps)
_document.ApplyOperation(inverseOp);
_redoStack.Push(entry);
}
public void Redo()
{
if (_redoStack.Count == 0) return;
var entry = _redoStack.Pop();
foreach (var op in entry.Operations)
{
var redoOp = new CrdtOperation
{
Type = op.Type,
Position = op.Position,
Value = op.Value,
UserId = _userId,
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
_document.ApplyOperation(redoOp);
}
_undoStack.Push(entry);
}
public bool CanUndo => _undoStack.Count > 0;
public bool CanRedo => _redoStack.Count > 0;
}
public class UndoEntry
{
public List<CrdtOperation> Operations { get; set; }
public DateTimeOffset Timestamp { get; set; }
public bool CanMergeWith(CrdtOperation nextOp)
{
return (nextOp.Timestamp - Timestamp).TotalMilliseconds < 500
&& nextOp.Type == Operations.Last().Type;
}
}
This undo manager implementation demonstrates the core pattern: track user operations, group consecutive operations for semantic undo, and generate inverse operations for undo/redo. The undo operations are themselves CRDT operations, ensuring that they commute with concurrent operations from other users and maintain convergence.
Undo with Concurrent Edits
When other users make concurrent edits while the current user is pressing undo, the undo operation must be transformed against those concurrent edits. Because undo operations are regular CRDT operations, this transformation is handled automatically by the CRDT merge algorithm. The undo operation will be placed at the correct position in the document, regardless of concurrent edits, because the CRDT's position assignment algorithm accounts for all concurrent operations.
However, there are edge cases where the undo operation may not produce the expected result. For example, if user A types hello, user B deletes the h, and then user A presses undo, the undo operation will try to remove hello but the h has already been deleted. In this case, the undo operation will remove the remaining ello, which is the best possible behavior given the concurrent deletion. The user may need to press undo multiple times to fully revert their changes, depending on the concurrent edits.
17. Testing and Consistency Verification
Testing a CRDT-based collaboration engine is fundamentally different from testing a single-user application. Because the system's correctness depends on the order and timing of operations across multiple replicas, traditional unit tests and integration tests are insufficient. This section examines the testing strategies and consistency verification techniques for CRDT-based systems.
Property-Based Testing
Property-based testing is the most important testing technique for CRDTs. Instead of testing specific input-output pairs, property-based testing generates random operations and verifies that the CRDT's invariants hold after each operation. The key properties to verify are: (1) Convergence: after all replicas apply the same set of operations in different orders, they reach the same state; (2) Commutativity: merge(A, B) = merge(B, A) for all states A and B; (3) Associativity: merge(merge(A, B), C) = merge(A, merge(B, C)) for all states A, B, and C; (4) Idempotency: merge(A, A) = A for all states A; and (5) Monotonicity: every operation moves the state upward in the partial order.
Property-based testing frameworks like FsCheck for C#, fast-check for JavaScript, and Hypothesis for Python generate random sequences of operations and verify that these properties hold. The test harness creates multiple replicas, applies random operations to each replica in different orders, and then verifies that all replicas converge to the same state. This approach catches bugs that are difficult to reproduce with manual testing, such as subtle ordering issues that only manifest after hundreds of concurrent operations.
Linearizability Testing
Linearizability testing verifies that the system behaves as if operations are executed atomically on a single copy of the data. While CRDTs are designed for eventual consistency rather than linearizability, testing for linearizability of the server-side aggregation pipeline ensures that the server correctly serializes and orders operations. Linearizability checkers like Knossos and Jepsen can be used to verify that the server-side pipeline produces a consistent linearizable history of operations.
Fuzz Testing
Fuzz testing generates random, potentially malformed inputs to test the CRDT's robustness against invalid operations. A fuzzer might generate operations with invalid positions, duplicate operation IDs, operations with timestamps in the past, or operations that reference non-existent elements. The CRDT should handle all of these gracefully, either by rejecting invalid operations or by processing them correctly despite the invalid inputs.
Fuzz testing is particularly important for the network protocol layer, where malformed messages from buggy or malicious clients must not crash the server. The WebSocket message parser should be fuzzed with random byte sequences, truncated messages, and messages with invalid JSON. The operation validator should be fuzzed with operations that have invalid types, out-of-range positions, and missing required fields.
Integration Testing
Integration tests verify the end-to-end behavior of the collaboration engine, including the WebSocket transport, server-side aggregation, and client-side CRDT merge. A typical integration test sets up multiple client instances, connects them to the server, has each client perform a series of operations, and then verifies that all clients converge to the same document state. The test may also simulate network partitions, message reordering, and disconnections to verify that the system handles these scenarios correctly.
| Testing Strategy | What It Tests | Coverage | Tool Example | When to Run |
|---|---|---|---|---|
| Property-based | CRDT algebraic properties | High (random operations) | FsCheck, fast-check | Every commit |
| Linearizability | Server-side ordering | High (all serializations) | Knossos, Jepsen | Nightly |
| Fuzz testing | Robustness against invalid inputs | Medium (edge cases) | AFL, libFuzzer | Weekly |
| Integration | End-to-end behavior | Medium (common paths) | Playwright, xUnit | Every commit |
| Chaos testing | Failure resilience | Low (specific failures) | Chaos Monkey, Toxiproxy | Pre-release |
| Performance | Latency and throughput | Low (specific workloads) | k6, Bombardier | Pre-release |
Consistency Verification in Production
Even with comprehensive testing, production systems can develop consistency issues due to software bugs, hardware failures, or network partitions. Production consistency verification involves periodically checking that all replicas of a document have converged to the same state. The server can perform this check by comparing the state vectors of all connected clients and verifying that they are consistent with the authoritative state vector.
If a divergence is detected, the server can trigger an automatic repair by sending the authoritative state to the diverged client. The server should also log the divergence event for debugging and alerting. In practice, CRDT divergence should be extremely rare because convergence is mathematically guaranteed by the CRDT properties. Divergence typically indicates a bug in the CRDT implementation, the network protocol, or the serialization layer, all of which should be investigated promptly.
C#
public class ConsistencyVerifier
{
private readonly IDocumentManager _documentManager;
private readonly IConnectionManager _connectionManager;
private readonly ILogger<ConsistencyVerifier> _logger;
public async Task<ConsistencyReport> VerifyDocumentAsync(string documentId)
{
var document = await _documentManager.GetDocumentAsync(documentId);
var connections = _connectionManager.GetConnections(documentId);
var report = new ConsistencyReport { DocumentId = documentId };
foreach (var conn in connections)
{
var clientSV = conn.GetLastReportedStateVector();
var serverSV = document.CrdtState.GetStateVector();
if (!IsConsistent(clientSV, serverSV))
{
report.Divergences.Add(new Divergence
{
ClientId = conn.UserId,
ClientVector = clientSV,
ServerVector = serverSV
});
_logger.LogWarning(
"Consistency divergence detected: doc={DocId}, client={ClientId}",
documentId, conn.UserId);
await RepairClientAsync(conn, document);
}
}
report.TotalClients = connections.Count;
report.DivergenceCount = report.Divergences.Count;
return report;
}
private bool IsConsistent(byte[] clientSV, byte[] serverSV)
{
var clientMap = DeserializeStateVector(clientSV);
var serverMap = DeserializeStateVector(serverSV);
return clientMap.All(kvp =>
serverMap.GetValueOrDefault(kvp.Key, 0) >= kvp.Value);
}
private async Task RepairClientAsync(
IClientConnection conn, LoadedDocument document)
{
var fullState = document.CrdtState.EncodeFullState();
await conn.SendAsync(new SyncMessage
{
Type = SyncType.Repair,
Payload = fullState
});
}
}
This consistency verifier implementation demonstrates the pattern for production consistency checking: compare client state vectors against the authoritative state, detect divergences, and automatically repair diverged clients. The repair mechanism sends the full authoritative state to the diverged client, ensuring that it converges to the correct state.
Chaos Engineering for Collaboration
Chaos engineering techniques are valuable for testing the resilience of the collaboration engine. By intentionally injecting failures such as network partitions, message drops, server crashes, and storage failures, chaos testing verifies that the system recovers correctly and maintains consistency. Key chaos scenarios for a collaboration engine include: server crash during operation processing (operations should be re-processed from the operation log), network partition between clients (clients should continue editing offline and sync on reconnection), Redis failure (presence state should be lost but document state should be preserved), and S3 outage (operations should be buffered in memory and persisted when S3 recovers).
18. Interview Q&A
This section contains 10 frequently asked interview questions about designing a CRDT-based real-time collaboration engine. These questions are designed for senior-level system design interviews and cover the key architectural decisions, trade-offs, and implementation details discussed throughout this article.
Question 1: Why choose CRDTs over Operational Transformation for a collaborative editor?
Answer: CRDTs offer several advantages over OT for most collaborative editing scenarios. First, CRDTs provide a mathematical guarantee of convergence through the algebraic properties of the underlying data structures (commutativity, associativity, idempotency), whereas OT convergence depends on the correctness of transformation functions that must be maintained manually. Second, CRDTs naturally support offline editing and P2P synchronization because convergence does not require a central server, while OT requires a central server for operation ordering. Third, CRDTs are simpler to implement correctly because the complexity is in the data structure rather than the protocol. The main advantage of OT is bandwidth efficiency for real-time editing (only operations are transmitted), but delta-state CRDTs close this gap significantly. For most new collaborative editing systems, CRDTs are the recommended choice unless there are specific requirements that OT handles better, such as very large document support or specific rich text formatting semantics.
Question 2: How does the system handle two users typing at the exact same position simultaneously?
Answer: When two users insert text at the same position simultaneously, the CRDT merge algorithm deterministically orders the insertions. Different CRDT algorithms use different ordering rules. RGA uses timestamp and replica ID: the character with the larger timestamp comes first, and in case of a tie, the character with the smaller replica ID comes first. YATA (used by Yjs) uses a favor-left rule that tends to place later insertions to the right. All replicas will converge to the same ordering regardless of the order in which they receive the operations, because the ordering is deterministic and based on metadata that is consistent across all replicas. The user-visible result is that both users' text appears in the document, in a position that is consistent with the CRDT's ordering algorithm. Neither user's text is lost, which preserves the intention of both users.
Question 3: What happens when a client goes offline for an extended period and comes back online?
Answer: When a client reconnects after an extended disconnection, it enters the sync reconciliation phase. The client sends its version vector to the server, which compares it with the authoritative version vector. If the number of missed operations is small (below a configurable threshold such as 1000), the server sends the individual operations as a delta. If the number is large, suggesting a long disconnection, the server sends the full CRDT state as a snapshot. The client merges the received data into its local state using the CRDT merge function. Because the merge function is commutative, associative, and idempotent, all conflicts between the client's offline edits and the server's operations are automatically resolved. The client's offline edits are preserved and integrated into the document, and no data is lost regardless of how long the client was offline. The key invariant is that the client's local CRDT state is always a valid state that can be merged with the server's state to produce a correct result.
Question 4: How do you handle tombstone accumulation in a long-running collaborative editor?
Answer: Tombstones accumulate as users delete content, and they consume memory without providing user-visible value. Tombstones can only be garbage-collected when all replicas have observed the deletion. The server tracks the minimum version vector across all connected clients (the checkpoint version) and garbage-collects tombstones whose deletion timestamp is older than the checkpoint. The garbage collection runs periodically (e.g., every hour) and removes tombstones from the CRDT data structure. When a client reconnects with a version vector that is older than the checkpoint, the server sends the full CRDT state instead of a delta, because the delta may reference tombstones that have been garbage-collected. For very long disconnections (days or weeks), the client may need to re-download the full document, but this is acceptable because such long disconnections are rare. The garbage collection interval and checkpoint age are tunable parameters that balance memory usage with reconnection bandwidth.
Question 5: How does the awareness protocol work, and why is it separate from the CRDT?
Answer: The awareness protocol manages ephemeral state like cursor positions, selection ranges, user names, and typing indicators. It is separate from the CRDT because awareness state has fundamentally different requirements: it is ephemeral (should not be persisted), it is user-specific (user A's cursor does not conflict with user B's cursor), and it does not need convergence guarantees (each user's cursor is independent). The awareness protocol uses a simple broadcast mechanism: each client sends its awareness state to the server, which broadcasts it to all other clients. Each client maintains a map of user ID to awareness state, with TTL-based expiration. When a user disconnects, their awareness state is removed after a timeout (e.g., 30 seconds). This separation of concerns is a key architectural pattern: CRDTs handle persistent document state with convergence guarantees, while the awareness protocol handles ephemeral presence state with simple broadcast semantics. Yjs provides a built-in awareness module that implements this pattern.
Question 6: How do you scale a collaboration engine to handle thousands of concurrent editors on a single document?
Answer: Scaling to thousands of concurrent editors on a single document requires several techniques: (1) Server-side operation batching where the server accumulates operations and broadcasts them in batches to reduce per-operation overhead; (2) Fan-out optimization where the server maintains an efficient list of connected clients and uses non-blocking I/O for broadcasting; (3) Connection sharding where clients are distributed across multiple WebSocket gateway instances using consistent hashing on the document ID; (4) Read-replica scaling where document state reads are served from read replicas while writes go to the primary; (5) Awareness throttling where cursor updates are throttled per-client to reduce the total awareness traffic; and (6) Delta compression where only the changes since each client's last sync are sent, minimizing per-client bandwidth. In practice, most collaborative editing systems support up to 50-100 concurrent editors per document before latency becomes noticeable. For larger groups (e.g., a webinar with 1000 viewers), a broadcast model where only a few editors can write and many users are read-only viewers is more appropriate.
Question 7: How does undo/redo work in a collaborative editor, and what are the challenges?
Answer: Undo/redo in a collaborative editor is complex because undo must only revert the current user's changes while preserving other users' changes. The standard approach is user-scoped undo: the undo manager tracks which operations were generated by the current user, and undo reverts only those operations. Undo operations are themselves CRDT operations (typically tombstone insertions for undoing inserts, or insertions for undoing deletes), which ensures that they commute with concurrent operations from other users and maintain convergence. The main challenges are: (1) semantic undo grouping where consecutive operations must be grouped into logical actions; (2) interaction with concurrent edits where undo may not produce the expected result if other users have modified the same content; and (3) undo stack management where the undo stack must be per-user and must not be shared across clients. In practice, most collaborative editors implement word-level or action-level undo with user scoping, and they accept that undo may not perfectly revert changes in the presence of heavy concurrent editing.
Question 8: What is the role of version vectors in a CRDT-based collaboration engine?
Answer: Version vectors serve three critical roles: (1) Sync reconciliation where the client sends its version vector to the server to determine which operations it has missed; the server compares the client's vector with the authoritative vector and sends only the missing operations; (2) Garbage collection where the server tracks the minimum version vector across all connected clients to determine which tombstones are safe to remove; tombstones older than the minimum version can be garbage-collected because all clients have observed the deletion; and (3) Causality tracking where version vectors track the happens-before relationship between operations, enabling the system to determine whether two operations are causally related or concurrent. Version vectors are typically encoded in a compact binary format for efficient network transmission. The Yjs library uses a state vector encoding that is typically only a few hundred bytes even for documents with many replicas.
Question 9: How do you ensure data consistency when the server crashes during operation processing?
Answer: Data consistency during server crashes is ensured through the operation log and idempotent processing. The operation log is the primary durability mechanism: every operation is appended to the log before it is merged into the authoritative state. If the server crashes after appending an operation to the log but before merging it into the authoritative state, the operation is replayed from the log during server startup. If the server crashes after merging but before broadcasting, the operation is re-broadcast to clients during the next synchronization cycle. The key invariant is that the operation log is append-only and never modified, so it always contains the complete history of operations. The authoritative state is reconstructed by replaying the operation log from the beginning, which may take time for large documents. To speed up recovery, the server periodically creates snapshots of the authoritative state and stores them in durable storage. During startup, the server loads the most recent snapshot and replays only the operations that occurred after the snapshot, significantly reducing recovery time.
Question 10: What are the trade-offs between Yjs, Automerge, and Diamond Types for building a collaborative editor?
Answer: Yjs, Automerge, and Diamond Types are the three dominant CRDT libraries for text editing, each with different trade-offs. Yjs implements the YATA algorithm and is the most widely used library in production. It has the best ecosystem (plugins for ProseMirror, CodeMirror, Quill, Monaco), the most features (rich text, nested structures, awareness), and good performance. It is written in JavaScript with optional C/C++ bindings. Automerge implements a JSON-like CRDT model written in Rust with WASM bindings. It provides the most developer-friendly API and the best support for structured documents (not just text). Its columnar encoding format is very space-efficient for snapshots. Diamond Types, by Joseph Gentle, focuses on maximum performance for text editing. It is significantly faster than both Yjs and Automerge for text workloads but has a more limited feature set (no rich text, no maps). For most production applications, Yjs is the recommended choice because of its maturity and ecosystem. Automerge is better for applications that need a structured document model. Diamond Types is worth considering for high-performance text editing where raw speed is critical.