| name | yjs-server |
| description | Deploy Yjs collaborative editing. YjsServer setup with compaction threshold, Caddy reverse proxy with flush_interval -1 for SSE, 3-layer architecture (Browser → Caddy → YjsServer → DS Server), Electric Cloud managed alternative with @electric-sql/cli provisioning. Load when deploying y-durable-streams to production or configuring server infrastructure.
|
| type | core |
| library | durable-streams |
| library_version | 0.2.3 |
| requires | ["yjs-getting-started"] |
| sources | ["durable-streams/durable-streams:packages/y-durable-streams/src/server/yjs-server.ts","durable-streams/durable-streams:packages/y-durable-streams/src/server/compaction.ts","durable-streams/durable-streams:examples/yjs-demo/server.ts","durable-streams/durable-streams:examples/yjs-demo/Caddyfile"] |
This skill builds on durable-streams/yjs-getting-started. Read it first for
basic setup.
Durable Streams — Yjs Server Deployment
Three deployment options: dev server for prototyping, Caddy for self-hosted
production, Electric Cloud for managed hosting.
Architecture
Browser (YjsProvider)
│ HTTPS
▼
Caddy reverse proxy (:443)
├─ /v1/stream/* → Durable Streams storage
└─ /v1/yjs/* → YjsServer (flush_interval -1)
│ HTTP
▼
DS Server (storage)
YjsServer implements the Yjs wire protocol (snapshot discovery, compaction,
awareness routing) and proxies all storage operations to a Durable Streams
server.
Development
import { DurableStreamTestServer } from "@durable-streams/server"
import { YjsServer } from "@durable-streams/y-durable-streams/server"
const dsServer = new DurableStreamTestServer({ port: 4437 })
await dsServer.start()
const yjsServer = new YjsServer({
port: 4438,
host: "127.0.0.1",
dsServerUrl: "http://localhost:4437",
compactionThreshold: 1024 * 1024,
})
await yjsServer.start()
Single-origin dev server (HTTP/2 multiplexing)
For local development you usually want one origin the browser hits so HTTP/2
can multiplex the DS stream, Yjs stream, and the Vite dev server over a
single connection. Spawn Caddy from a Node script alongside YjsServer:
import { spawn } from "node:child_process"
import { resolve } from "node:path"
import { YjsServer } from "@durable-streams/y-durable-streams/server"
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"
const CADDY_PORT = 4443
const YJS_PORT = 4438
const yjsServer = new YjsServer({
port: YJS_PORT,
host: "127.0.0.1",
dsServerUrl: `https://localhost:${CADDY_PORT}`,
compactionThreshold: 1024 * 1024,
})
await yjsServer.start()
const caddy = spawn(
resolve(import.meta.dirname, "./durable-streams-server"),
["run", "--config", resolve(import.meta.dirname, "./Caddyfile")],
{ stdio: ["ignore", "pipe", "pipe"] }
)
await new Promise<void>((ok, fail) => {
const t = setTimeout(() => fail(new Error("Caddy start timeout")), 10_000)
caddy.stderr.on("data", (buf: Buffer) => {
if (buf.toString().includes("serving initial configuration")) {
clearTimeout(t)
ok()
}
})
caddy.on("exit", (code) => {
clearTimeout(t)
if (code && code !== 0) fail(new Error(`Caddy exited ${code}`))
})
})
process.on("SIGINT", async () => {
await yjsServer.stop()
caddy.kill("SIGTERM")
process.exit(0)
})
And the matching dev Caddyfile — DS at /v1/stream/*, Yjs proxied to the
internal YjsServer, everything else to Vite:
{
admin off
}
localhost:4443 {
route /v1/stream/* {
durable_streams
}
route /v1/yjs/* {
reverse_proxy localhost:4438 {
flush_interval -1
}
}
reverse_proxy localhost:3001 # Vite dev server
}
The flush_interval -1 on the Yjs route is mandatory (see Common Mistakes
below). Keep the dev and production Caddyfiles consistent on this flag.
YjsServer options
| Option | Default | Description |
|---|
port | — | Listen port |
host | "127.0.0.1" | Listen host |
dsServerUrl | — | Backing DS server URL |
compactionThreshold | 1048576 (1MB) | Trigger compaction after this many bytes of updates |
dsServerHeaders | {} | Headers sent to the DS server (e.g. auth) |
Compaction
When accumulated updates for a document exceed compactionThreshold, the
server automatically creates a snapshot. New clients load the snapshot
instead of replaying all updates — keeps initial sync fast. Connected clients
are unaffected.
Production with Caddy
Download the Caddy binary with the durable_streams plugin from
GitHub releases.
Caddyfile
:443 {
route /v1/stream/* {
durable_streams {
data_dir ./data
max_file_handles 200
}
}
route /v1/yjs/* {
reverse_proxy localhost:4438 {
flush_interval -1
}
}
}
flush_interval -1 is mandatory — without it, Caddy buffers SSE
responses and live updates stop working. This is the #1 production
deployment mistake.
Production YjsServer
Point YjsServer at the Caddy server (not the raw DS server) if Caddy handles
TLS:
const yjsServer = new YjsServer({
port: 4438,
dsServerUrl: "https://localhost:443",
compactionThreshold: 1024 * 1024,
})
Managed with Electric Cloud
Skip infrastructure setup entirely. Provision a Yjs service via the
Electric Cloud CLI:
npx @electric-sql/cli auth login
npx @electric-sql/cli services create yjs --json
npx @electric-sql/cli services get-secret <service-id> --json
Then point YjsProvider at the cloud URL:
const provider = new YjsProvider({
doc,
baseUrl: "https://api.electric-sql.cloud/v1/yjs/<service-id>",
docId: "my-doc",
headers: {
Authorization: `Bearer <secret>`,
},
})
Server-side proxy (required for browser apps)
Do NOT expose the Electric Cloud secret to browser clients. Use a
server-side proxy route that injects the Authorization header:
app.all("/api/yjs/*", async (req, res) => {
const targetUrl = `https://api.electric-sql.cloud/v1/yjs/<service-id>${req.path.replace("/api/yjs", "")}`
const response = await fetch(targetUrl, {
method: req.method,
headers: {
...req.headers,
Authorization: `Bearer ${process.env.YJS_SECRET}`,
},
body: req.method !== "GET" ? req.body : undefined,
duplex: "half",
})
const skipHeaders = new Set([
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
])
for (const [key, value] of response.headers) {
if (!skipHeaders.has(key.toLowerCase())) {
res.setHeader(key, value)
}
}
res.status(response.status)
response.body.pipe(res)
})
Key proxy rules:
- Use a block-list for response headers — Yjs protocol uses custom
headers like
stream-next-offset that an allow-list would miss
- Block
content-encoding and content-length — Node's fetch
auto-decompresses gzip but leaves the headers, causing
ERR_CONTENT_DECODING_FAILED
- Use
duplex: "half" when forwarding request bodies
Then point the provider at your proxy:
const provider = new YjsProvider({
doc,
baseUrl: "/api/yjs",
docId: "my-doc",
})
Common Mistakes
CRITICAL Missing flush_interval -1 in Caddy config
Wrong:
route /v1/yjs/* {
reverse_proxy localhost:4438
}
Correct:
route /v1/yjs/* {
reverse_proxy localhost:4438 {
flush_interval -1
}
}
Without this, Caddy buffers SSE responses. Live updates appear to hang —
clients connect but never receive data.
HIGH Exposing Electric Cloud secret to browser clients
Wrong:
new YjsProvider({
doc,
baseUrl: "https://api.electric-sql.cloud/v1/yjs/<service-id>",
headers: { Authorization: `Bearer ${cloudSecret}` },
})
Correct: Use a server-side proxy that injects the secret. See the proxy
section above.
MEDIUM Not configuring compaction threshold
Default is 1MB. For documents with frequent small edits (collaborative text),
this is reasonable. For documents with large binary content (images, files),
increase it to avoid excessive compaction I/O.
See also