Yjs
Sync Yjs CRDT documents over Durable Streams using plain HTTP — no WebSocket infrastructure needed.
y-durable-streams provides a Yjs provider and server that handle snapshot discovery, live updates via long-polling or SSE, automatic server-side compaction, snapshot availability webhooks, and optional awareness (presence) for cursors and user status.
Installation
npm install @durable-streams/y-durable-streams yjs y-protocols lib0yjs, y-protocols, and lib0 are peer dependencies.
Quick start
import { YjsProvider } from "@durable-streams/y-durable-streams"
import * as Y from "yjs"
import { Awareness } from "y-protocols/awareness"
const doc = new Y.Doc()
const awareness = new Awareness(doc)
const provider = new YjsProvider({
doc,
baseUrl: "http://localhost:4438/v1/yjs/my-service",
docId: "my-document",
awareness,
})
provider.on("synced", (synced) => {
console.log("Synced:", synced)
})The provider connects automatically, discovers the latest snapshot, loads it, then streams live updates.
Provider options
interface YjsProviderOptions {
doc: Y.Doc // Yjs document to sync
baseUrl: string // Server URL, e.g. "http://localhost:4438/v1/yjs/my-service"
docId: string // Document ID (can include slashes, e.g. "project/chapter-1")
awareness?: Awareness // Optional awareness for presence
headers?: HeadersRecord // Optional auth headers
liveMode?: "sse" | "long-poll" // Live update transport (default: "sse")
connect?: boolean // Auto-connect on construction (default: true)
}Events
// Sync state changes
provider.on("synced", (synced: boolean) => {
if (synced) {
console.log("Document is synced with server")
}
})
// Connection status changes
provider.on("status", (status: "disconnected" | "connecting" | "connected") => {
console.log("Status:", status)
})
// Error handling
provider.on("error", (error: Error) => {
console.error("Provider error:", error)
})Lifecycle
// Manual connection
const provider = new YjsProvider({
doc,
baseUrl,
docId,
connect: false, // Don't connect automatically
})
provider.on("synced", handleSync)
provider.on("error", handleError)
await provider.connect()
// Disconnect temporarily
provider.disconnect()
// Reconnect
await provider.connect()
// Destroy permanently
provider.destroy()Always call destroy() when done to clean up event listeners and close connections.
Authentication
const provider = new YjsProvider({
doc,
baseUrl: "http://localhost:4438/v1/yjs/my-service",
docId: "my-document",
awareness,
headers: {
Authorization: "Bearer your-token",
},
})Awareness (presence)
Pass an Awareness instance to enable presence support. y-durable-streams broadcasts local awareness state (cursors, selections, user info) and subscribes to remote awareness updates via a separate SSE stream.
import * as Y from "yjs"
import { Awareness } from "y-protocols/awareness"
import { YjsProvider } from "@durable-streams/y-durable-streams"
const doc = new Y.Doc()
const awareness = new Awareness(doc)
const provider = new YjsProvider({
doc,
baseUrl: "http://localhost:4438/v1/yjs/my-service",
docId: "my-document",
awareness,
})
// Set local user presence
awareness.setLocalStateField("user", {
name: "Alice",
color: "#ff0000",
})
// Listen for remote awareness changes
awareness.on("change", () => {
const states = awareness.getStates()
console.log("Online users:", states.size)
})Awareness heartbeats are sent every 15 seconds. When the provider disconnects, it broadcasts a removal so other clients see the user go offline immediately.
How it works
y-durable-streams uses a four-step sync protocol over HTTP. For the full wire format and details, see the Yjs Durable Streams Protocol specification.
- Snapshot discovery — Requests
?offset=snapshot. The server responds with a 307 redirect to the latest snapshot offset, or to-1if no snapshot exists. - Snapshot loading — Fetches the binary Yjs snapshot and applies it to the local document. The response includes a
stream-next-offsetheader indicating where to continue. - Live updates — Streams incremental updates from the offset via long-polling or SSE. Local edits are sent through an idempotent producer for exactly-once delivery.
- Awareness — An optional separate SSE stream carries presence data (cursors, selections, user info) using named awareness channels.
Compaction
y-durable-streams automatically compacts documents when accumulated updates exceed a size threshold. Compaction creates a new snapshot at the current offset, keeping initial sync fast for new clients. This is transparent to connected clients — existing connections continue uninterrupted.
Snapshot webhooks
Create a service-scoped webhook subscription to persist snapshots in external storage:
await fetch(`${baseUrl}/__ds/subscriptions/snapshot-archive`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "webhook",
events: ["snapshot.available"],
document_pattern: "projects/**",
webhook: { url: "https://archive.example/hooks/yjs" },
lease_ttl_ms: 30_000,
}),
})document_pattern is relative to the service. * matches one path segment and ** matches recursively. The underlying Durable Streams server must support webhook subscriptions.
The signed webhook is a wake notification and may represent more than one compaction. For every pending .index stream in the payload, derive the document path, request its current snapshot with ?offset=snapshot, and persist the returned binary. Respond with { "done": true } only after persistence succeeds; failed deliveries are retried by the Durable Streams subscription system.
Subscriptions observe future snapshots. They do not immediately deliver the snapshot that was current when the subscription was created.
URL structure
Each document is accessed via a single URL with query parameters:
{baseUrl}/docs/{docPath}?{queryParams}Where docPath can include forward slashes (e.g., project/chapter-1).
Deployment
Development
Use DurableStreamTestServer + YjsServer as shown in the quick start. See the deployment guide for details.
Production (self-hosted)
Run the Caddy binary with the durable_streams plugin for storage, and reverse-proxy to YjsServer:
:443 {
route /v1/stream/* {
durable_streams {
data_dir ./data
}
}
route /v1/yjs/* {
reverse_proxy localhost:4438 {
flush_interval -1
}
}
}flush_interval -1 is required — without it, Caddy buffers SSE responses and live updates stop working.
Managed (Electric Cloud)
Deploy on Electric Cloud for managed hosting with no infrastructure to maintain.
npx @electric-sql/cli services create yjs --json
npx @electric-sql/cli services get-secret <service-id> --jsonPoint baseUrl at the cloud URL and pass the secret as an Authorization header. For browser apps, use a server-side proxy to avoid exposing the secret.
Best practices
Always call destroy(). Clean up providers when unmounting components or leaving documents.
useEffect(() => {
const provider = new YjsProvider({ doc, baseUrl, docId, awareness })
return () => provider.destroy()
}, [])Use hierarchical document paths. Organize documents with forward slashes for logical grouping.
// Good
docId: "org/project/chapter-1"
// Also works
docId: "simple-doc"Handle errors gracefully. y-durable-streams automatically reconnects on transient failures, but listen for errors to update the UI.
provider.on("error", (error) => {
showToast("Connection issue — retrying...")
})Editor integrations
TipTap v3
npm install @tiptap/react @tiptap/starter-kit \
@tiptap/extension-collaboration @tiptap/extension-collaboration-caretImportant: Use
@tiptap/extension-collaboration-caret, not@tiptap/extension-collaboration-cursor. The-cursorpackage is a broken v3 stub.
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import Collaboration from "@tiptap/extension-collaboration"
import CollaborationCaret from "@tiptap/extension-collaboration-caret"
const editor = useEditor({
extensions: [
StarterKit.configure({ undoRedo: false }),
Collaboration.configure({ document: ydoc }),
CollaborationCaret.configure({ provider, user: { name, color } }),
],
})CodeMirror 6
npm install codemirror @codemirror/state @codemirror/view y-codemirror.nextimport { yCollab } from "y-codemirror.next"
const ytext = ydoc.getText("content")
const state = EditorState.create({
doc: ytext.toString(),
extensions: [basicSetup, yCollab(ytext, awareness)],
})See the Yjs demo for a complete CodeMirror example.
Learn more
- Yjs protocol specification — full protocol spec
- Package README — complete API reference
- Yjs demo — collaborative text editor example
- Yjs docs — Yjs documentation