How to Build Real-Time Collaborative Applications: 8 Proven Techniques With Code

javascript dev.to

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Building real-time collaborative applications means creating spaces where multiple people can work together on the same document, design, or dataset at the exact same time, from different computers. I remember the first time I tried to build one. I set up a basic WebSocket connection, and it worked—until two users edited the same sentence. Their changes collided, and the document became a jumbled mess. This is the central challenge: making sure everyone sees the same thing, even when they're all changing it simultaneously.

Today, we have solid answers to that problem. Let's walk through eight practical methods for building these applications. We'll start with the network layer and work our way up to the data structures that ensure consistency.

The first step is establishing a reliable, persistent connection between the browser and the server. The HTTP protocol is built for request and response, like a walkie-talkie where you have to say "over" each time. For constant, two-way conversation, we need something more like a telephone line. This is where WebSockets come in.

A WebSocket connection starts with an HTTP handshake and then upgrades to a full-duplex TCP connection. Once open, both the client and server can send messages to each other at any time. This is perfect for sending instant updates about cursor movements, typed characters, or changes to a shared drawing.

However, networks are unreliable. Connections drop, servers restart, and Wi-Fi falters. A robust application must handle this gracefully. A simple connection script isn't enough. We need a manager that can reconnect automatically, queue messages while offline, and confirm that important messages are delivered.

Here is a more complete WebSocket wrapper class. It manages reconnection with a backoff strategy, queues messages if the connection is down, and includes a heartbeat to detect dead connections.

class ReliableWebSocket {
  constructor(url) {
    this.url = url;
    this.socket = null;
    this.isConnected = false;
    this.reconnectTimer = null;
    this.messageQueue = [];
    this.heartbeatInterval = null;

    // Track reconnect attempts with increasing delay
    this.reconnectAttempts = 0;
    this.maxReconnectDelay = 30000; // 30 seconds max

    this.connect();
  }

  connect() {
    try {
      this.socket = new WebSocket(this.url);
      this.setupSocketHandlers();
    } catch (error) {
      console.error('Connection setup failed:', error);
      this.scheduleReconnect();
    }
  }

  setupSocketHandlers() {
    this.socket.onopen = () => {
      console.log('Connected to server.');
      this.isConnected = true;
      this.reconnectAttempts = 0; // Reset on successful connection
      this.startHeartbeat();
      this.flushMessageQueue(); // Send any queued messages
    };

    this.socket.onclose = (event) => {
      console.log(`Connection closed. Code: ${event.code}, Reason: ${event.reason}`);
      this.isConnected = false;
      this.stopHeartbeat();
      this.scheduleReconnect();
    };

    this.socket.onerror = (error) => {
      console.error('WebSocket error:', error);
    };

    this.socket.onmessage = (event) => {
      this.handleIncomingMessage(event.data);
    };
  }

  send(data) {
    const message = JSON.stringify(data);
    if (this.isConnected && this.socket.readyState === WebSocket.OPEN) {
      this.socket.send(message);
    } else {
      // Store message to send when we reconnect
      this.messageQueue.push(message);
    }
  }

  flushMessageQueue() {
    while (this.messageQueue.length > 0 && this.isConnected) {
      const msg = this.messageQueue.shift();
      this.socket.send(msg);
    }
  }

  scheduleReconnect() {
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);

    // Calculate delay: 1s, then 1.5s, then 2.25s, etc., capped at 30s
    const baseDelay = 1000;
    const delay = Math.min(
      baseDelay * Math.pow(1.5, this.reconnectAttempts),
      this.maxReconnectDelay
    );

    this.reconnectAttempts++;
    console.log(`Reconnecting in ${Math.round(delay)}ms...`);

    this.reconnectTimer = setTimeout(() => this.connect(), delay);
  }

  startHeartbeat() {
    // Send a ping every 20 seconds to keep connection alive
    this.heartbeatInterval = setInterval(() => {
      if (this.isConnected) {
        this.send({ type: 'ping', timestamp: Date.now() });
      }
    }, 20000);
  }

  stopHeartbeat() {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval);
      this.heartbeatInterval = null;
    }
  }

  handleIncomingMessage(rawData) {
    try {
      const data = JSON.parse(rawData);
      // Ignore ping/pong messages internally, or handle them
      if (data.type === 'pong') {
        // Server responded to our ping, connection is alive
        return;
      }
      // ... process other message types like 'update', 'cursor', etc.
      console.log('Received:', data);
    } catch (e) {
      console.error('Failed to parse message:', e);
    }
  }

  disconnect() {
    this.stopHeartbeat();
    if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
    if (this.socket) {
      this.socket.close(1000, 'Client closed connection');
    }
  }
}

// Usage
const ws = new ReliableWebSocket('wss://collab.example.com/socket');
ws.send({ type: 'join', documentId: 'doc_123' });
Enter fullscreen mode Exit fullscreen mode

With a stable connection, we can start broadcasting events. The simplest pattern is for a client to send its change to a server, which then broadcasts that exact change to all other connected clients. This is operational transformation, and while it works for basic cases, it gets complex when dealing with network latency and conflicts. This leads us to the second technique: using conflict-free replicated data types, or CRDTs.

A CRDT is a kind of data structure designed to be copied across multiple locations (like different users' browsers). Each copy can be modified independently, even while offline. When you reconnect and sync, the mathematical properties of the CRDT guarantee that all copies will eventually become identical. You don't need a central server to resolve conflicts; the data structure itself defines how to merge changes.

Think of it like a set where you can only add items. If you have two copies of a set, and one adds "A" while the other adds "B", when they merge, the result is a set containing both "A" and "B". It's conflict-free because adding different items never clashes. This is called a Grow-Only Set (G-Set).

class GrowOnlySet {
  constructor() {
    this.items = new Set();
  }

  add(element) {
    this.items.add(element);
  }

  contains(element) {
    return this.items.has(element);
  }

  merge(otherSet) {
    // To merge, just add all items from the other set
    for (let element of otherSet.items) {
      this.items.add(element);
    }
  }

  getValues() {
    return Array.from(this.items);
  }
}

// User A's browser
const setA = new GrowOnlySet();
setA.add('Task 1');

// User B's browser, offline initially
const setB = new GrowOnlySet();
setB.add('Task 2');

// They connect and merge
setA.merge(setB);
setB.merge(setA);

console.log(setA.getValues()); // ['Task 1', 'Task 2']
console.log(setB.getValues()); // ['Task 1', 'Task 2'] - Now identical!
Enter fullscreen mode Exit fullscreen mode

Of course, we need more than grow-only sets. We need to handle removal. A Two-Phase Set (2P-Set) uses two G-Sets: one for additions and one for removals. An item is in the set only if it's in the "added" set and not in the "removed" set. Once removed, it can never be added again, which works for many use cases.

class TwoPhaseSet {
  constructor() {
    this.added = new GrowOnlySet();
    this.removed = new GrowOnlySet();
  }

  add(element) {
    this.added.add(element);
  }

  remove(element) {
    // You can only remove an element if it has been added
    if (this.added.contains(element)) {
      this.removed.add(element);
    }
  }

  contains(element) {
    return this.added.contains(element) && !this.removed.contains(element);
  }

  merge(otherSet) {
    this.added.merge(otherSet.added);
    this.removed.merge(otherSet.removed);
  }

  getValues() {
    return this.added.getValues().filter(item => !this.removed.contains(item));
  }
}
Enter fullscreen mode Exit fullscreen mode

For collaborative text editing—like a Google Doc—the problem is order. We need a sequence CRDT that maintains an ordered list of characters when multiple people are typing and deleting at different positions. This is more complex.

One approach is to give every character a unique, sortable position identifier, not a simple index. Imagine a position is not "5" but a fingerprint like [ {digit: 12, site: 'userA'}, {digit: 5, site: 'userA'} ]. When you insert between two existing positions, you generate a new fingerprint that lies between them lexicographically. This way, every character has a permanent, global position that doesn't shift when others insert text before it.

Here's a simplified look at the core idea, inspired by algorithms like Logoot or RGA:

class TextCRDT {
  constructor(siteId) {
    this.siteId = siteId || this.generateId();
    this.sequence = []; // Array of { position: [...], char: 'a' }
  }

  generateId() {
    return Math.random().toString(36).substring(2, 15);
  }

  // Creates a position between 'before' and 'after'
  createPosition(before, after) {
    // Simplified: Use a fractional index.
    // In reality, you'd use a list of tuples with a site identifier.
    let low = before ? before.index : 0;
    let high = after ? after.index : 1;
    let newIndex = (low + high) / 2;

    // Include siteId to break ties if two users pick the same fraction
    return { index: newIndex, site: this.siteId };
  }

  insertAt(localIndex, char) {
    // localIndex is where the user *thinks* they're inserting (like cursor position 5)
    // We need to find the global positions before and after that point.
    let beforePos = localIndex > 0 ? this.sequence[localIndex - 1].position : null;
    let afterPos = localIndex < this.sequence.length ? this.sequence[localIndex].position : null;

    let newPosition = this.createPosition(beforePos, afterPos);
    let newEntry = { position: newPosition, char: char };

    // Insert, maintaining order by position
    let insertIndex = 0;
    while (insertIndex < this.sequence.length &&
           this.comparePositions(this.sequence[insertIndex].position, newPosition) < 0) {
      insertIndex++;
    }
    this.sequence.splice(insertIndex, 0, newEntry);

    // Return an operation to send to others
    return { type: 'insert', pos: newPosition, char: char, site: this.siteId };
  }

  comparePositions(posA, posB) {
    // First compare the numeric index
    if (posA.index !== posB.index) {
      return posA.index - posB.index;
    }
    // If equal, compare site IDs for a deterministic tie-breaker
    return posA.site.localeCompare(posB.site);
  }

  applyRemoteOperation(op) {
    if (op.type === 'insert') {
      let newEntry = { position: op.pos, char: op.char };
      let insertIndex = 0;
      while (insertIndex < this.sequence.length &&
             this.comparePositions(this.sequence[insertIndex].position, op.pos) < 0) {
        insertIndex++;
      }
      // Only insert if not already present (idempotent)
      if (this.sequence[insertIndex]?.position !== op.pos) {
        this.sequence.splice(insertIndex, 0, newEntry);
      }
    }
    // ... handle 'delete' operations similarly
  }

  getText() {
    return this.sequence.map(entry => entry.char).join('');
  }
}

// Simulating collaboration
const user1CRDT = new TextCRDT('user1');
const user2CRDT = new TextCRDT('user2');

// User 1 types 'Hello' at the start
let op1 = user1CRDT.insertAt(0, 'H');
user2CRDT.applyRemoteOperation(op1); // Sync

let op2 = user1CRDT.insertAt(1, 'e');
user2CRDT.applyRemoteOperation(op2);

// User 2 types 'i' at position 1 (between 'H' and 'e')
let op3 = user2CRDT.insertAt(1, 'i');
user1CRDT.applyRemoteOperation(op3);

console.log(user1CRDT.getText()); // 'Hie'
console.log(user2CRDT.getText()); // 'Hie' - Consistent!
Enter fullscreen mode Exit fullscreen mode

The fourth technique is about state synchronization. Sending every single keystroke as an operation is fine, but sometimes you just need to sync the entire document state after being offline. This is where having a CRDT that can be serialized to a compact format and merged is vital.

We can design a register CRDT that holds a value (like a JSON object) and uses logical timestamps to decide which update wins when there's a conflict. Each update is tagged with a vector clock—a counter per replica—that lets us compare the order of events.

class LWWRegister {
  constructor(replicaId) {
    this.replicaId = replicaId;
    this.value = null;
    this.timestamp = 0; // Simplified scalar timestamp for demo
  }

  set(newValue, newTimestamp = Date.now()) {
    if (newTimestamp > this.timestamp) {
      this.value = newValue;
      this.timestamp = newTimestamp;
    } else if (newTimestamp === this.timestamp && this.replicaId < replicaIdOfNew) {
      // Tie-breaker: replica with lower ID wins, ensuring consistency
      this.value = newValue;
    }
  }

  merge(otherRegister) {
    // Take the value with the highest timestamp
    if (otherRegister.timestamp > this.timestamp) {
      this.value = otherRegister.value;
      this.timestamp = otherRegister.timestamp;
    } else if (otherRegister.timestamp === this.timestamp) {
      // Tie-break by replicaId
      if (otherRegister.replicaId < this.replicaId) {
        this.value = otherRegister.value;
        this.replicaId = otherRegister.replicaId;
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The fifth technique focuses on presence. In a collaborative app, it's helpful to see who else is online, where their cursors are, or what they're selected. This is often called "awareness." We can model this using a CRDT that maps user IDs to their current state.

We broadcast periodic heartbeat updates for each user. If we stop receiving heartbeats from a user after a timeout, we consider them offline. This state can be stored in a CRDT map, so the list of online users converges across all clients.

class Awareness {
  constructor(localUserId) {
    this.localState = { cursor: null, name: 'Anonymous', color: '#ff0000' };
    this.states = new Map(); // userId -> { state, lastUpdated }
    this.localUserId = localUserId;

    // Update server every 2 seconds
    setInterval(() => this.broadcastUpdate(), 2000);
  }

  broadcastUpdate() {
    // Send our current state to others via WebSocket
    const message = {
      type: 'awareness',
      userId: this.localUserId,
      state: this.localState,
      timestamp: Date.now()
    };
    // ws.send(message);
  }

  processIncoming(message) {
    const { userId, state, timestamp } = message;
    const existing = this.states.get(userId);

    // Use last-write-wins based on timestamp
    if (!existing || timestamp > existing.lastUpdated) {
      this.states.set(userId, { state, lastUpdated: timestamp });
    }

    // Cleanup old entries (e.g., older than 10 seconds)
    const now = Date.now();
    for (let [id, data] of this.states.entries()) {
      if (now - data.lastUpdated > 10000 && id !== this.localUserId) {
        this.states.delete(id);
      }
    }
  }

  getOnlineUsers() {
    return Array.from(this.states.keys());
  }
}
Enter fullscreen mode Exit fullscreen mode

The sixth technique is operational compression. In a lively editing session, you might generate hundreds of operations per minute. Sending each one individually is wasteful. Instead, we can buffer operations for a short time (like 100 milliseconds) and send them in batches. On the receiving end, we apply the entire batch. This reduces network overhead and can improve perceived performance.

class OperationBatcher {
  constructor(sendCallback, batchWindow = 100) {
    this.batch = [];
    this.sendCallback = sendCallback; // Function to send batched ops
    this.batchWindow = batchWindow;
    this.timer = null;
  }

  addOperation(op) {
    this.batch.push(op);
    if (!this.timer) {
      this.timer = setTimeout(() => this.flush(), this.batchWindow);
    }
  }

  flush() {
    if (this.batch.length > 0) {
      this.sendCallback(this.batch);
      this.batch = [];
    }
    if (this.timer) {
      clearTimeout(this.timer);
      this.timer = null;
    }
  }
}

// Usage in editor
const batcher = new OperationBatcher((ops) => {
  ws.send({ type: 'opsBatch', operations: ops });
});

// On each keystroke:
editor.on('change', (changeOp) => {
  batcher.addOperation(changeOp);
});
Enter fullscreen mode Exit fullscreen mode

The seventh technique is about handling offline work. A user might close their laptop or lose internet. When they come back online, they need to sync all changes made while disconnected. This requires the client to persist operations locally (using IndexedDB or localStorage) and maintain a version vector.

When reconnecting, the client sends its vector clock to the server, which responds with all operations it missed. This is similar to how version control systems like Git sync changes.

class OfflineManager {
  constructor() {
    this.pendingOperations = [];
    this.lastSyncedVersion = 0;
    this.db = this.initDB();
  }

  async initDB() {
    // Open IndexedDB database
    return new Promise((resolve) => {
      // ... IndexedDB setup code
    });
  }

  async saveOperationLocally(op) {
    this.pendingOperations.push(op);
    await this.db.save('pending_ops', op);
  }

  async syncOnReconnect(websocket) {
    // Send our last known version
    websocket.send({ type: 'sync', version: this.lastSyncedVersion });

    // Server will respond with missing ops
  }

  async processSyncResponse(missingOps) {
    for (let op of missingOps) {
      await this.applyOperation(op);
    }
    // Also send our pending ops to server
    for (let op of this.pendingOperations) {
      await this.sendOperation(op);
    }
    this.pendingOperations = [];
  }
}
Enter fullscreen mode Exit fullscreen mode

The eighth and final technique is testing and simulation. Building a collaborative system is complex. One powerful method is to write deterministic simulations that run multiple virtual clients in a single JavaScript process. They can apply random operations, go offline, reconnect, and then verify that all clients end up with the same final state. This can find bugs in your CRDT merge logic that are hard to catch otherwise.

async function testConvergence() {
  const clients = [];
  const network = new SimulatedNetwork(); // Delays, drops messages

  // Create 3 virtual clients with their own CRDT
  for (let i = 0; i < 3; i++) {
    clients.push(new TextCRDT(`client${i}`));
  }

  // Simulate random typing on each client
  for (let step = 0; step < 100; step++) {
    const clientIdx = Math.floor(Math.random() * clients.length);
    const client = clients[clientIdx];
    const op = client.insertAt(
      Math.floor(Math.random() * client.getText().length),
      String.fromCharCode(97 + Math.floor(Math.random() * 26))
    );

    // Broadcast to other clients via simulated network
    network.broadcast(op, clientIdx);
  }

  // Force final sync
  network.deliverAllMessages();

  // Check if all clients have identical text
  const firstText = clients[0].getText();
  const allMatch = clients.every(c => c.getText() === firstText);
  console.assert(allMatch, 'CRDTs did not converge!');
  console.log('Test passed:', allMatch);
}
Enter fullscreen mode Exit fullscreen mode

Bringing it all together, building a real-time collaborative application is about layering these techniques. You start with a reliable WebSocket connection. You choose CRDTs that match your data model—sets for checklists, sequences for text, registers for configuration. You add presence for a live feel, batch operations for efficiency, and robust offline support. Finally, you test thoroughly with simulations.

I've found that the key is to start simple. Implement a basic G-Set for a shared to-do list. Get the WebSocket broadcasting working. Then incrementally add complexity: allow removals, add cursors, handle offline edits. Each layer builds confidence. The mathematics behind CRDTs can seem daunting, but the practical implementation boils down to defining how to merge two pieces of data. Start with that merge function, and everything else follows.

The result is a genuinely collaborative experience where multiple users can interact seamlessly, with the system quietly ensuring consistency beneath the surface. It's challenging work, but seeing multiple cursors move in harmony across a shared document for the first time makes it all worthwhile.

📘 Checkout my latest ebook for free on my channel!

Be sure to like, share, comment, and subscribe to the channel!


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Source: dev.to

arrow_back Back to Tutorials