Modern Web

Offline-First Synchronization: Syncing Loro CRDTs over WebRTC DataChannels

Master decentralized collaboration. Sync ultra-fast Rust-powered Loro CRDT document states directly peer-to-peer across browsers over WebRTC.

Sachin Sharma
Sachin SharmaCreator
Jun 4, 2026
5 min read
Offline-First Synchronization: Syncing Loro CRDTs over WebRTC DataChannels
Featured Resource
Quick Overview

Master decentralized collaboration. Sync ultra-fast Rust-powered Loro CRDT document states directly peer-to-peer across browsers over WebRTC.

Offline-First Synchronization: Syncing Loro CRDTs over WebRTC DataChannels

Most collaborative applications depend on a central database server (like Google Docs or Figma) to receive client updates, order edits, and resolve collisions. While this is straightforward, it forces a dependency on expensive server infrastructure and breaks when users go offline.

Local-First architectures prioritize client-side data ownership. By using Conflict-Free Replicated Data Types (CRDTs), documents can merge conflict-free on any device in any order.

While Yjs is the standard JS CRDT library, Loro is the next-generation, high-performance CRDT framework written in Rust (compiled to WebAssembly). It is up to 100x faster than traditional JS CRDTs.

In this guide, we'll design a decentralized collaborative workspace that synchronizes Loro CRDT document states directly peer-to-peer (P2P) between browser tabs over WebRTC DataChannels.


⚡ 1. The P2P Synchronization Architecture

In a WebRTC P2P sync system, there is no master server. Devices connect directly via encrypted peer channels.

  1. 2.
    Loro Doc (Local): Holds the local document state. Any keypress triggers a local update.
  2. 4.
    Export Local Updates: When a local change occurs, Loro generates a lightweight binary delta (update packet).
  3. 6.
    WebRTC DataChannel: Delivers this binary packet directly to the connected peer over UDP.
  4. 8.
    Import Peer Updates: The receiving peer imports the binary delta into their local Loro Doc. Loro resolves logical clocks instantly and merges changes.
[User A Types] ──> [Loro Doc (A)] ──(Export Delta Binary)
                                           │
                              [WebRTC DataChannel P2P Link]
                                           │
(Import Delta Binary) ──> [Loro Doc (B)] ──> [User B Screen Updates]

🏗️ 2. Instantiating the Loro Document

Let's initialize our Loro document in JavaScript. We'll set up a shared map for document metadata and a shared text object for the main text editor.

javascript
import { Loro } from 'loro-crdt'; class CollaborativeDocument { constructor() { // 1. Initialize Loro Document this.doc = new Loro(); // 2. Create a shared text buffer this.text = this.doc.getText('editor-buffer'); // 3. Setup change listener this.text.subscribe((event) => { if (event.local) { // Local edit: export change binary to send to peers const update = this.doc.export({ mode: "update" }); this.broadcastUpdateToPeers(update); } else { // Remote edit: update our UI this.updateTextareaUI(); } }); } // Receive dynamic remote updates from peer connections receivePeerUpdate(binaryUpdate) { // Import peer updates; Loro merges conflict-free! this.doc.import(binaryUpdate); this.updateTextareaUI(); } updateTextareaUI() { const textarea = document.getElementById('collab-textarea'); if (textarea && textarea.value !== this.text.toString()) { textarea.value = this.text.toString(); } } }

💻 3. Setting Up the WebRTC P2P DataChannel

To connect two browsers peer-to-peer, we use WebRTC. We'll write a clean wrapper to initialize an RTCPeerConnection and open a reliable binary RTCDataChannel.

javascript
class PeerConnectionManager { constructor(signalingServerUrl, roomId, onBinaryReceived) { this.signaling = new WebSocket(signalingServerUrl); this.roomId = roomId; this.onBinaryReceived = onBinaryReceived; this.peerConn = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] }); this.setupSignaling(); this.setupDataChannel(); } setupDataChannel() { // Create data channel (configured for reliable transmission) this.dataChannel = this.peerConn.createDataChannel('loro-sync', { ordered: true }); this.dataChannel.binaryType = 'arraybuffer'; this.dataChannel.onmessage = (event) => { const arrayBuffer = event.data; const binaryData = new Uint8Array(arrayBuffer); // Callback to import updates into Loro this.onBinaryReceived(binaryData); }; // Handle incoming data channel from target peer this.peerConn.ondatachannel = (event) => { const channel = event.channel; channel.binaryType = 'arraybuffer'; channel.onmessage = (e) => { this.onBinaryReceived(new Uint8Array(e.data)); }; this.dataChannel = channel; }; } setupSignaling() { this.signaling.onmessage = async (message) => { const data = JSON.parse(message.data); if (data.offer) { await this.peerConn.setRemoteDescription(new RTCSessionDescription(data.offer)); const answer = await this.peerConn.createAnswer(); await this.peerConn.setLocalDescription(answer); this.sendSignal({ answer }); } else if (data.answer) { await this.peerConn.setRemoteDescription(new RTCSessionDescription(data.answer)); } else if (data.candidate) { await this.peerConn.addIceCandidate(new RTCIceCandidate(data.candidate)); } }; this.peerConn.onicecandidate = (event) => { if (event.candidate) { this.sendSignal({ candidate: event.candidate }); } }; } sendSignal(payload) { this.signaling.send(JSON.stringify({ roomId: this.roomId, ...payload })); } sendBinary(data) { if (this.dataChannel && this.dataChannel.readyState === 'open') { this.dataChannel.send(data.buffer); } } }

🚀 4. Connecting Loro and WebRTC together

Let's link the Loro document and our WebRTC data manager together to complete the real-time sync cycle.

javascript
const collabDoc = new CollaborativeDocument(); const peerManager = new PeerConnectionManager( 'wss://signaling.sachinsharma.dev', 'collaborative-room-101', (binaryData) => { // 1. Peer sends update -> Import into local Loro Document collabDoc.receivePeerUpdate(binaryData); } ); // 2. Bind Loro updates to broadcast through the WebRTC data channel collabDoc.broadcastUpdateToPeers = (uint8ArrayUpdate) => { peerManager.sendBinary(uint8ArrayUpdate); }; // 3. Connect text inputs to local Loro updates const editorTextarea = document.getElementById('collab-textarea'); editorTextarea.addEventListener('input', (event) => { const value = event.target.value; // Calculate character diffs and update Loro const currentLength = collabDoc.text.toString().length; collabDoc.text.delete(0, currentLength); collabDoc.text.insert(0, value); });

📊 5. Synchronization Performance

By compiling the core CRDT mathematical engines down to WASM (using Loro) and bypassing server-side routing entirely:

  • Latency: Reduced from ~80ms (client-to-server-to-client) to ~5ms (direct client-to-peer local network transit).
  • Server Cost: Reduced to $0 (after signaling handshake, traffic flows entirely peer-to-peer).
  • Security: Encrypted using WebRTC DTLS/SRTP protocols natively, preventing middle-man server inspection.

🏁 6. Conclusion

Building collaborative features on the web no longer requires complex server databases. By linking Rust-engineered WASM CRDT libraries like Loro with WebRTC P2P DataChannels, you build robust, offline-first collaborative systems that deliver sub-10ms performance, complete user data ownership, and zero server hosting fees.

Sachin Sharma

Sachin Sharma

Software Developer

Building digital experiences at the intersection of design and code. Sharing weekly insights on engineering, productivity, and the future of tech.