DEV Community

Pyae Phyo Maung
Pyae Phyo Maung

Posted on

Building a Zero-Backend P2P Live Collaboration System in React with Yjs and WebRTC

If you spin up an authoritative WebSocket server for a collaborative spreadsheet, two problems hit you immediately:

  1. The Infrastructure Tax: You are now paying for idle socket connections, Redis pub/sub adapters, and persistent database sync pipelines for users who might just leave a tab open in the background.
  2. The Data Liability: Translation catalogs regularly contain confidential unreleased feature keys, internal system copy, and proprietary strings. The moment those strings transit an unencrypted central server, you inherit compliance and breach blast-radius liabilities.

For JSON Link, I wanted zero backend hosting costs and absolute data sovereignty.

Here is how the browser-native peer-to-peer collaboration engine is engineered using Yjs CRDTs, WebRTC DataChannels, and Web Crypto (AES-GCM-256).


The State Topology: Serverless Mesh

Traditional collaborative apps route every mutation through a central coordinator:

Browser A  ──[WebSocket]──>  [Central Node + Redis/DB]  ──[WebSocket]──>  Browser B
Enter fullscreen mode Exit fullscreen mode

Instead of managing server state, JSON Link runs a direct peer-to-peer data mesh:

Browser A  <══════ WebRTC DataChannel (Direct P2P) ══════>  Browser B
                     │                                │
                     └─── [Ephemeral Signaling Nodes] ───┘
Enter fullscreen mode Exit fullscreen mode
  • Signaling is strictly for discovery: Public STUN and WebSocket signaling servers only broker the initial SDP offer/answer handshake and ICE candidates. No document payload is ever stored on signaling nodes.
  • Direct browser transport: Once ICE negotiation completes, peers stream raw delta updates over SCTP-based WebRTC DataChannels. Latency drops to direct network ping (<20ms on LAN/regional connections).
  • Deterministic CRDT convergence: Concurrent keystrokes on the same table cell resolve automatically using Yjs state vectors and Lamport timestamps. There is no "last-write-wins" database overwrite.

Zero-Knowledge Signaling: Client-Side PBKDF2 + AES-GCM

Because public WebRTC signaling nodes can inspect transit packets during connection setup, room updates must be encrypted before leaving the browser.

When a room password or PIN is configured, the browser derives an AES-GCM 256-bit encryption key using PBKDF2-SHA256 with 100,000 iterations:

export async function deriveRoomKey(password: string, roomSalt: string): Promise<CryptoKey> {
  const enc = new TextEncoder();
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    enc.encode(password),
    { name: 'PBKDF2' },
    false,
    ['deriveKey']
  );

  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt: enc.encode(roomSalt),
      iterations: 100000,
      hash: 'SHA-256',
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}
Enter fullscreen mode Exit fullscreen mode

Every Yjs document delta is encrypted client-side with a unique 12-byte initialization vector (IV) before transmission. Even if an intermediary intercepts the signaling room traffic, all payloads remain opaque ciphertext.


Integrating Yjs into a Reactive Spreadsheet Grid

Binding CRDT updates to a React spreadsheet without triggering render thrashing requires separating the document state from ephemeral collaboration presence:

import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';

const ydoc = new Y.Doc();
const yTranslations = ydoc.getMap('translations');

// Establish encrypted P2P mesh across fallback signaling clusters
const provider = new WebrtcProvider('json-link-room-id', ydoc, {
  signaling: [
    'wss://y-webrtc.fly.dev',
    'wss://y-webrtc-signaling.fly.dev'
  ],
  password: roomPin, // Native PBKDF2-SHA256 derivation under the hood
});

// 1. Synchronize CRDT changes into the table model
yTranslations.observe(() => {
  setGridData(yTranslations.toJSON());
});

// 2. Ephemeral awareness (multiplayer cursors & focused cells)
provider.awareness.setLocalStateField('user', {
  name: 'Alex-42',
  color: '#10b981',
  pointer: { x: clientX, y: clientY },
  activeCell: { key: 'auth.login.title', field: 'en' }
});
Enter fullscreen mode Exit fullscreen mode

Where P2P Sucks (And How to Mitigate It)

Full-mesh WebRTC is not magic; it comes with real-world engineering constraints:

  1. The $O(N^2)$ Bandwidth Limit: In a full mesh, every peer connects to every other peer. For 3–6 collaborators working on a localization file, overhead is negligible (a few KB/s). Beyond 10–12 peers, upstream bandwidth multiplies rapidly. For JSON Link's target use-case (small product pods localizing software), full mesh is the sweet spot.
  2. NAT / Corporate Firewall Traversal: Strict symmetric NATs block direct peer-to-peer hole-punching. We bundle public STUN endpoints (stun:stun.l.google.com:19302) for standard NAT mapping, with self-hostable signaling flags for teams behind strict corporate firewalls.
  3. Offline Resilience & AST Protection: When a peer disconnects, their edits persist locally via the File System Access API (direct two-way disk synchronization with locales/*.json). On reconnect, Yjs automatically replays missing updates. To prevent non-technical contributors from accidentally breaking translation variables, an AST tokenizer locks ICU MessageFormat ({count, plural, ...}), Mustache, and Printf placeholders into immutable chips during live editing.

Code & Architecture

JSON Link is MIT licensed and runs 100% in the browser with no backend dependencies:

Top comments (0)