Real-Time Collaboration in Web Apps: Yjs, Liveblocks, and Conflict-Free Editing in Production
A practical deep-dive into building real-time collaborative editing features: comparing Yjs CRDTs, Liveblocks managed service, and PartyKit/Durable Objects, with TypeScript code for presence, persistence, and production concerns like document growth and garbage collection.
You add collaborative editing to your product. Two users open the same document. User A types at the top. User B types at the bottom. It works. You ship it.
Three weeks later, user A goes offline on a train, edits for forty minutes, comes back online, and their changes corrupt a section user B had been working on for an hour. You have no undo history past the last save point. The document is 8 MB and growing.
This is where most collaborative editing implementations quietly fail. The demo works. The edge cases do not.
Real-time collaboration is harder than it looks because you are solving three separate problems simultaneously: conflict resolution across concurrent edits, presence (who is where, and what are they looking at), and persistence that survives network partitions without data loss. Each one is genuinely hard on its own.
This article covers the three main approaches, with real TypeScript, real tradeoffs, and the production concerns that bite you six months after launch.
Why Naive Approaches Break
The simplest approach is last-write-wins: whoever sends their update last wins. This is what you get if you broadcast changes over WebSockets without any conflict resolution strategy. It seems fine in a demo with two users on the same network. Under real conditions it corrupts documents.
Operational Transform (OT) is the original solution, used by Google Docs. The idea: transform incoming operations relative to the operations that have already been applied locally. It works, but correctness requires a central server that serializes all operations into a canonical order. That server becomes a bottleneck and a single point of failure. Implementing OT correctly, including all the transformation functions for different operation types, is extremely difficult. Most open-source OT implementations have known edge cases.
CRDTs (Conflict-free Replicated Data Types) take a different approach: design data structures that always merge deterministically, regardless of the order operations arrive. No central coordination required. Two peers can each make changes offline and merge cleanly when they reconnect. The tradeoff is that CRDT data structures carry more metadata, and that metadata grows over time.
Approach 1: Yjs (Self-Hosted CRDT)
Yjs is the most production-mature CRDT library for JavaScript. It implements a variant of the LOGOOT/LSEQ algorithm adapted for rich text and supports collaborative maps, arrays, and text. The core library is small and fast. The ecosystem handles persistence (y-indexeddb), sync (y-websocket, y-webrtc), and editor bindings (for Prosemirror, TipTap, CodeMirror, Slate, Lexical).
Setting Up a Yjs Document with React
import * as Y from "yjs";
import { WebsocketProvider } from "y-websocket";
import { useEffect, useRef, useState } from "react";
interface CollaborativeEditorProps {
documentId: string;
userId: string;
userColor: string;
userName: string;
}
function useYjsDocument(documentId: string) {
const docRef = useRef<Y.Doc | null>(null);
const providerRef = useRef<WebsocketProvider | null>(null);
useEffect(() => {
const doc = new Y.Doc();
docRef.current = doc;
// y-websocket handles reconnection automatically
const provider = new WebsocketProvider(
`wss://your-sync-server.example.com`,
documentId,
doc,
{ connect: true }
);
providerRef.current = provider;
return () => {
provider.disconnect();
doc.destroy();
};
}, [documentId]);
return { docRef, providerRef };
}
The Y.Doc is the root container. Every collaborative data structure lives inside it. Operations are tracked as updates, not as full snapshots, and Yjs merges them using its internal state vector.
Presence and Awareness
Awareness is Yjs’s protocol for ephemeral shared state: cursor positions, user names, colors, selection ranges. It is separate from the document state and does not persist. When a user disconnects, their awareness entry is cleaned up automatically after a timeout.
import { Awareness } from "y-protocols/awareness";
interface UserAwareness {
user: {
name: string;
color: string;
id: string;
};
cursor: {
anchor: number | null;
head: number | null;
} | null;
}
function setupAwareness(
provider: WebsocketProvider,
userId: string,
userName: string,
userColor: string
) {
const awareness = provider.awareness;
// Set local user state
awareness.setLocalStateField("user", {
id: userId,
name: userName,
color: userColor,
});
awareness.setLocalStateField("cursor", null);
// React to other users' state
const handleChange = (
changes: { added: number[]; updated: number[]; removed: number[] }
) => {
const states = awareness.getStates() as Map<number, UserAwareness>;
// Filter out local user (clientID)
const remoteUsers = Array.from(states.entries())
.filter(([clientId]) => clientId !== awareness.clientID)
.map(([, state]) => state);
// Update your UI with remote cursors
updateRemoteCursors(remoteUsers);
};
awareness.on("change", handleChange);
return () => {
awareness.off("change", handleChange);
};
}
function updateRemoteCursors(users: UserAwareness[]) {
// Map cursor positions to your editor's coordinate system
// This is editor-specific: TipTap, CodeMirror, Prosemirror each
// expose their own cursor APIs
}
Awareness updates are gossiped over the same WebSocket connection as document updates. They are not persisted. If your sync server restarts, presence state is rebuilt as clients reconnect.
Persisting Document State
Client-side persistence uses y-indexeddb. This allows offline editing without a network connection. The document is loaded from IndexedDB first, then synced with the server:
import { IndexeddbPersistence } from "y-indexeddb";
function useYjsPersistence(doc: Y.Doc, documentId: string) {
const persistenceRef = useRef<IndexeddbPersistence | null>(null);
const [isSynced, setIsSynced] = useState(false);
useEffect(() => {
const persistence = new IndexeddbPersistence(documentId, doc);
persistenceRef.current = persistence;
persistence.on("synced", () => {
// Local data has been loaded. Now the WebSocket provider can
// merge server state on top of the local state.
setIsSynced(true);
});
return () => {
persistence.destroy();
};
}, [doc, documentId]);
return { isSynced };
}
Server-side, you store the document as a binary Yjs update. Do not store the raw text content separately unless you need it for search. The canonical state is the Yjs update blob:
import * as Y from "yjs";
import { fromUint8Array, toUint8Array } from "js-base64";
// Store: encode to base64 for database storage
async function saveDocument(
documentId: string,
doc: Y.Doc,
db: DatabaseClient
): Promise<void> {
const state = Y.encodeStateAsUpdate(doc);
const encoded = fromUint8Array(state);
await db.query(
`INSERT INTO documents (id, state, updated_at)
VALUES ($1, $2, NOW())
ON CONFLICT (id) DO UPDATE
SET state = $2, updated_at = NOW()`,
[documentId, encoded]
);
}
// Load: decode from base64 and apply to a new doc
async function loadDocument(
documentId: string,
db: DatabaseClient
): Promise<Y.Doc> {
const row = await db.queryOne(
`SELECT state FROM documents WHERE id = $1`,
[documentId]
);
const doc = new Y.Doc();
if (row?.state) {
const update = toUint8Array(row.state);
Y.applyUpdate(doc, update);
}
return doc;
}
Where Yjs gets complex is running the sync server. y-websocket provides a reference server implementation, but you own the infrastructure: scaling to multiple instances, handling the in-memory document state, and deciding when to flush updates to your database.
Approach 2: Liveblocks (Managed Service)
Liveblocks handles the sync infrastructure and gives you a typed API for presence, storage (Yjs-compatible or its own Storage type), and comments/notifications. You bring your editor binding; they handle the WebSocket rooms, scaling, and history.
The core model: a Room is a collaboration session. Clients join rooms. Storage and presence live in the room.
import { createClient } from "@liveblocks/client";
import { createRoomContext } from "@liveblocks/react";
// Shared types for your collaboration session
type Presence = {
cursor: { x: number; y: number } | null;
userName: string;
userColor: string;
};
type Storage = {
// Liveblocks LiveObject wraps your data with CRDT semantics
document: LiveObject<{
title: string;
version: number;
}>;
};
const client = createClient({
authEndpoint: "/api/liveblocks-auth",
});
export const {
RoomProvider,
useMyPresence,
useOthers,
useStorage,
useMutation,
} = createRoomContext<Presence, Storage>(client);
The auth endpoint issues a token scoped to the room. This is where you enforce access control:
// /api/liveblocks-auth.ts (Next.js Route Handler)
import { Liveblocks } from "@liveblocks/node";
import { NextRequest, NextResponse } from "next/server";
const liveblocks = new Liveblocks({
secret: process.env.LIVEBLOCKS_SECRET_KEY!,
});
export async function POST(request: NextRequest) {
const { room } = await request.json();
const session = await getServerSession(); // your auth
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Verify the user has access to this room/document
const hasAccess = await checkDocumentAccess(session.user.id, room);
if (!hasAccess) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const lbSession = liveblocks.prepareSession(session.user.id, {
userInfo: {
name: session.user.name ?? "Anonymous",
color: getUserColor(session.user.id),
},
});
lbSession.allow(room, lbSession.FULL_ACCESS);
const { status, body } = await lbSession.authorize();
return new NextResponse(body, { status });
}
Liveblocks has a Yjs integration that lets you use your existing TipTap or Prosemirror setup while Liveblocks handles the sync:
import { LiveblocksYjsProvider } from "@liveblocks/yjs";
import * as Y from "yjs";
import { useRoom } from "./liveblocks.config";
import { useEffect, useRef } from "react";
function CollaborativeEditor() {
const room = useRoom();
const editorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const doc = new Y.Doc();
const provider = new LiveblocksYjsProvider(room, doc);
const yText = doc.getText("content");
// Initialize TipTap, CodeMirror, etc. with yText
const editor = initEditor(editorRef.current!, yText, provider.awareness);
return () => {
editor.destroy();
provider.destroy();
doc.destroy();
};
}, [room]);
return <div ref={editorRef} />;
}
Where Liveblocks wins: you do not manage the sync server, you do not worry about horizontal scaling, and you get presence and history out of the box. Where it costs you: every active room connection is billed, you have limited control over the storage layer, and your documents live in Liveblocks’s infrastructure.
Approach 3: PartyKit and Cloudflare Durable Objects
PartyKit (now part of Cloudflare) gives you a lightweight way to write stateful edge workers. A Party is essentially a named Durable Object that handles its own WebSocket connections. You write the sync logic yourself, but you get global low-latency routing and the Durable Object model for free.
// party/document.ts
import type * as Party from "partykit/server";
import * as Y from "yjs";
export default class DocumentParty implements Party.Server {
private doc: Y.Doc;
private awareness: Map<string, unknown>;
constructor(private room: Party.Room) {
this.doc = new Y.Doc();
this.awareness = new Map();
}
async onStart() {
// Load persisted state from Durable Object storage
const stored = await this.room.storage.get<string>("yjsState");
if (stored) {
const update = Buffer.from(stored, "base64");
Y.applyUpdate(this.doc, update);
}
}
async onMessage(message: string | ArrayBuffer, sender: Party.Connection) {
if (!(message instanceof ArrayBuffer)) return;
const data = new Uint8Array(message);
const messageType = data[0];
if (messageType === 0) {
// Yjs sync message: apply to doc and broadcast
const update = data.slice(1);
Y.applyUpdate(this.doc, update);
// Persist after each update (debounce this in production)
await this.persistDoc();
// Broadcast to all other connections
this.room.broadcast(message, [sender.id]);
} else if (messageType === 1) {
// Awareness message: rebroadcast without applying to doc
this.room.broadcast(message, [sender.id]);
}
}
async onClose(connection: Party.Connection) {
// Remove awareness state for disconnected client
this.awareness.delete(connection.id);
}
private async persistDoc() {
const state = Y.encodeStateAsUpdate(this.doc);
const encoded = Buffer.from(state).toString("base64");
await this.room.storage.put("yjsState", encoded);
}
}
This approach gives you full control. You can add authorization at the connection level, implement custom persistence strategies, and instrument everything. The cost is that you are writing more code and owning more of the infrastructure behavior.
One specific advantage over Liveblocks: with Durable Objects, the document state lives in Cloudflare’s edge network. Connections route to the nearest region that has the object, and all WebSocket traffic for a room hits the same Durable Object instance. No need to externalize lock management or pub/sub.
Tradeoffs
| Dimension | Yjs (self-hosted) | Liveblocks | PartyKit / Durable Objects |
|---|---|---|---|
| Infrastructure burden | High (you run the sync server) | None | Low (serverless, but you write server code) |
| Vendor lock-in | None | High (auth, storage, billing in Liveblocks) | Medium (Cloudflare-specific primitives) |
| Offline support | Excellent (y-indexeddb) | Limited (online-required for storage sync) | You implement it |
| Presence | Built-in (awareness protocol) | Built-in (typed, with userInfo) | You implement it (or use y-protocols/awareness) |
| History / undo | Yjs UndoManager | Built-in (timeline, comments) | You implement it |
| Cost at scale | Fixed infra cost | Per-connection billing | Per-request + duration billing |
| Debugging | Hard (binary update format) | Dashboard + version history | Standard Cloudflare tooling |
| Custom auth | Full control | Auth endpoint pattern | Full control |
| Document growth | Requires GC strategy (see below) | Managed by Liveblocks | Requires GC strategy |
When Operational Transform Still Makes Sense
OT is not obsolete. If your use case has a canonical server (not peer-to-peer, not offline-first) and a simple operation set (insert, delete, attribute), OT can be simpler to reason about than CRDTs because you do not carry CRDT metadata overhead.
OT works well for: code editors in hosted IDEs (always online, central server, simple character operations), spreadsheet cell editing (atomic cell values, not rich text), form field collaboration (one field = one user, brief lock windows).
OT is a poor fit for: offline-first apps, peer-to-peer sync, rich text with complex formatting, scenarios where network partitions are common.
For everything else in 2026, CRDTs are the better default. The tooling (Yjs, Automerge) has matured enough that you are not implementing the theory yourself.
Production Concerns
Document Size Growth
Every edit in Yjs is stored as a structured operation in the document’s update log. Deletions do not immediately remove data; they create tombstones. Over time, a heavily edited document accumulates a large history that inflates the binary size.
Two mechanisms help:
Garbage collection: Yjs has a gc flag on Y.Doc. When enabled, deleted content is removed from memory after it has been acknowledged by all connected peers. Enable it for production documents that do not need full undo history across all time.
// GC enabled: deleted content is cleaned up
const doc = new Y.Doc({ gc: true });
// GC disabled: full history retained, required for complete undo/redo
const docWithHistory = new Y.Doc({ gc: false });
Snapshot compaction: Periodically encode the current state as a clean snapshot and use that as the new baseline. Discard the incremental update log older than the snapshot. This requires that all active clients are online or can sync against the new snapshot:
async function compactDocument(documentId: string, db: DatabaseClient) {
const doc = await loadDocument(documentId, db);
// Encode current state: all content, no history
const snapshot = Y.encodeStateAsUpdate(doc);
// Replace incremental updates with a single baseline snapshot
await db.query(
`UPDATE documents
SET state = $2, compacted_at = NOW()
WHERE id = $1`,
[documentId, Buffer.from(snapshot).toString("base64")]
);
}
Run compaction during low-traffic windows. After compaction, clients that have been offline for longer than your compaction interval cannot sync incrementally and need to reload the full document.
Undo/Redo Across Collaborators
Yjs’s UndoManager undoes your local operations only, not operations from other users. This is the correct behavior for a collaborative editor: undoing should not undo what someone else typed.
import { UndoManager } from "yjs";
function setupUndoManager(doc: Y.Doc) {
const yText = doc.getText("content");
const undoManager = new UndoManager(yText, {
// Only track local user operations
trackedOrigins: new Set([doc.clientID]),
// Batch operations within 500ms into a single undo step
captureTimeout: 500,
});
return {
undo: () => undoManager.undo(),
redo: () => undoManager.redo(),
canUndo: () => undoManager.canUndo(),
canRedo: () => undoManager.canRedo(),
};
}
If you need cross-user undo (rollback to a previous state), that is a snapshot restore operation, not an undo. Keep named snapshots at meaningful points and present them as version history, not undo steps.
Sync Server Scaling
A naive y-websocket server holds all active documents in memory. At a few dozen concurrent documents this is fine. At a few thousand, you hit memory limits and you cannot run multiple instances without routing all connections for a document to the same instance.
The standard solution: sticky sessions via a load balancer (client IP or a room-ID-based hash), combined with periodic flushes to a database. Redis pub/sub can relay updates between instances, but it adds latency and complexity. The cleaner solution is Durable Objects or PartyKit, where the routing is handled at the infrastructure level and you do not need to implement sticky sessions yourself.
Offline Clients Rejoining
When a client reconnects after being offline, Yjs exchanges state vectors to determine what updates each side is missing. This works well for short offline periods. For clients that have been offline for weeks, you may have compacted the document in the interim. The client’s state vector references operations that no longer exist in the server’s update log.
Handle this at the sync protocol level: if the server cannot fulfill a client’s state vector request (missing operations post-compaction), send the full current snapshot instead. The client applies it as the new baseline. Any local offline edits the client made are then re-applied on top.
Decision Framework
Start here: do you need offline editing?
If no, and you want to move fast: use Liveblocks. The Yjs integration works well, you get presence and history, and you avoid operating a sync server. The billing model scales predictably for most SaaS products.
If yes, or if you need full control over data residency: use Yjs with a self-hosted sync server or PartyKit. Self-hosted is more work but has no vendor dependency. PartyKit is the middle ground: you write the server code, but the infrastructure is managed.
If you have a simple, always-online use case with a small operation set (chat, spreadsheet cells, form fields): consider whether Yjs is even the right tool. A last-write-wins approach with a version counter and server-side conflict detection may be sufficient and far simpler.
If you are building peer-to-peer without a central server: Yjs with y-webrtc is the right choice. Liveblocks and PartyKit both require a central relay.
Production Considerations
- Track document size in bytes in your persistence layer. Alert when documents exceed 5 MB. That is a signal that compaction is overdue.
- Log the time between client reconnection and full sync completion. If this grows above 2-3 seconds for typical documents, your compaction cadence is too infrequent.
- Awareness updates arrive very frequently during active editing. Debounce cursor position updates to 50ms before broadcasting. The visual result is indistinguishable and the reduction in WebSocket traffic is significant.
- Do not store Yjs binary updates in a column type that re-encodes them (e.g., TEXT with UTF-8 validation). Use BYTEA in Postgres or a binary blob type. Base64 encoding for transport is fine; decode before storage.
- Version your document schema. If you add a new Y.Map key or change the structure of a shared type, existing documents need a migration. Encode a schema version in the document metadata and handle upgrades at load time.
- Test offline merge explicitly in your CI pipeline. Open a document, disconnect network, make edits on two simulated clients, reconnect, verify the merge. This is the scenario most collaborative editing bugs hide in.
The hard part of collaborative editing is not the real-time sync. It is the behavior at the edges: offline clients, long-running sessions, document growth, and undo semantics. Yjs solves the CRDT layer well. What you build around it determines whether the feature holds up in production.
Choose your infrastructure based on how much of the edge behavior you want to own. Liveblocks owns more of it for you. Self-hosted Yjs means you own all of it. PartyKit sits in between and gives you a reasonable escape hatch from both extremes.
More in Web Engineering
How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.
How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.
How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.
How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.