Self-Hosted Real-Time Collaborative Video Editing with CRDTs and WebCodecs
Quick Summary (TL;DR) Build a self-hosted collaborative video editor by combining CRDTs (Conflict-free Replicated Data Types) for conflict-free timeline synchronization and WebCodecs for low-latency, client-side media decoding/encoding. Use WebSockets for real-time transport, object storage (e.g., MinIO) for media, and JWT + RBAC for security. This guide walks through architecture, backend setup, CRDT timeline modeling, WebCodecs pipelines, scaling, and deployment.
Introduction to Collaborative Video Editing
Video editing has traditionally been a solitary craft: one editor, one workstation, one timeline. Modern production teams, however, need multiple editors, colorists, and reviewers working simultaneously on the same project. Cloud tools like Frame.io and Descript proved the demand—but they come with subscription costs, vendor lock-in, and data residency concerns.
This guide shows you how to build a self-hosted, real-time collaborative video editor. The two pillars are:
- CRDTs for synchronizing the editing timeline across users without a central lock or conflict resolution UI.
- WebCodecs for decoding and encoding video frames directly in the browser, eliminating server round-trips for previews.
By the end, you’ll have a working architecture, code patterns, and a deployment strategy you can extend into production.
Why Self-Host? Benefits and Trade-offs
Self-hosting collaborative media tools is not for everyone, but for teams handling sensitive footage (legal, medical, pre-release film), it’s often mandatory.
| Factor | Self-Hosted | SaaS Cloud |
|---|---|---|
| Data control | Full ownership, on-prem or private VPC | Vendor-controlled |
| Cost model | Fixed infra + ops labor | Per-seat / per-minute |
| Customization | Unlimited (plugins, codecs, workflows) | Limited to vendor API |
| Latency | Depends on your network topology | Globally distributed CDN |
| Maintenance | You own updates, backups, scaling | Vendor-managed |
| Compliance | Easier for HIPAA/GDPR/ITAR | Requires vendor audits |
The trade-off is clear: you gain control and long-term cost predictability at the expense of operational responsibility. For small teams, SaaS wins. For studios with 10+ editors and strict data policies, self-hosting pays off.
Core Technologies: CRDTs and WebCodecs
CRDTs are data structures that guarantee convergence: any two replicas that have seen the same set of operations will hold identical state, regardless of message order. This makes them ideal for timelines where users drag clips, trim edges, and adjust keyframes concurrently.
WebCodecs exposes VideoDecoder, VideoEncoder, VideoFrame, and AudioData APIs directly to JavaScript. It bypasses the <video> element’s opacity, letting you decode specific frames on demand—critical for scrubbing and real-time preview under collaborative edits.
Together they form a clean separation of concerns:
- CRDTs handle what the timeline looks like (state).
- WebCodecs handles how frames are rendered (compute).
Architecture Overview
flowchart TD
A[Browser Client A] -->|WebSocket| S[Sync Server]
B[Browser Client B] -->|WebSocket| S
C[Browser Client C] -->|WebSocket| S
S --> D[(PostgreSQL Metadata)]
S --> E[(Redis Pub/Sub)]
A -->|Chunked Upload| F[MinIO Object Storage]
B -->|Chunked Upload| F
S -->|Presigned URLs| F
A -->|WebCodecs Decode| G[Local Frame Buffer]
B -->|WebCodecs Decode| G
Clients hold a local CRDT replica and a WebCodecs decoder. The sync server relays CRDT operations over WebSockets and persists snapshots to PostgreSQL. Redis pub/sub fans out operations across server instances. Media lives in MinIO, fetched via presigned URLs and cached in the browser’s Cache API.
Setting Up the Backend
Start with Node.js and Fastify for the API, ws for WebSockets, PostgreSQL for metadata, and MinIO for blobs.
npm init -y
npm install fastify @fastify/websocket ws pg ioredis minio jsonwebtoken
A minimal Fastify bootstrap:
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
const app = Fastify({ logger: true });
await app.register(websocket);
app.get('/health', async () => ({ status: 'ok' }));
app.register(async (instance) => {
instance.get('/ws/:projectId', { websocket: true }, (conn, req) => {
const { projectId } = req.params;
conn.socket.send(JSON.stringify({ type: 'welcome', projectId }));
conn.socket.on('message', (raw) => {
// Relay or process CRDT update
instance.websocketServer.clients.forEach((c) => {
if (c !== conn.socket && c.readyState === 1) c.send(raw);
});
});
});
});
await app.listen({ port: 3000, host: '0.0.0.0' });
For production, add Redis pub/sub so multiple server instances share updates, and persist periodic CRDT snapshots to PostgreSQL.
Implementing CRDTs for Timeline Synchronization
Model the timeline as a CRDT document. Yjs is the pragmatic choice—it has mature bindings, awareness (presence), and efficient binary encoding.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://your-host', 'project-42', doc);
// Timeline as a Y.Array of clip objects
const timeline = doc.getArray('timeline');
// Add a clip
timeline.push([{
id: crypto.randomUUID(),
assetId: 'asset-abc',
start: 0, // timeline position (ms)
duration: 5000, // clip length (ms)
inPoint: 1000, // source offset (ms)
trackIndex: 0
}]);
// Concurrent edits converge automatically
timeline.observe(() => {
renderTimeline(timeline.toArray());
});
For property-level edits (trimming, moving), use Y.Map per clip so two users editing different properties don’t conflict:
const clip = new Y.Map();
clip.set('start', 0);
clip.set('duration', 5000);
timeline.push([clip]);
Design tip: Keep the CRDT schema flat and normalized. Store asset metadata separately, referenced by assetId. This keeps operation payloads small and merge cost low.
| CRDT Library | Strengths | Best For |
|---|---|---|
| Yjs | Mature, fast, awareness protocol | General timeline sync |
| Automerge | Rich history, JSON-native | Audit-heavy workflows |
| Loro | Rust core, fast, movable list | Large documents |
Leveraging WebCodecs for Client-Side Processing
WebCodecs lets you decode only the frames you need. Here’s a minimal decoder that seeks to a timestamp:
const decoder = new VideoDecoder({
output: (frame) => {
// Draw to canvas or OffscreenCanvas
ctx.drawImage(frame, 0, 0);
frame.close();
},
error: (e) => console.error(e),
});
decoder.configure({
codec: 'avc1.640028',
codedWidth: 1920,
codedHeight: 1080,
hardwareAcceleration: 'prefer-hardware'
});
// Feed EncodedVideoChunks from a demuxer (e.g., mp4box.js)
function seekTo(chunk) {
decoder.decode(chunk);
}
For a scrubbing preview, prefetch keyframes around the target time and decode forward. For export, use VideoEncoder:
const encoder = new VideoEncoder({
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
error: (e) => console.error(e)
});
encoder.configure({
codec: 'vp09.00.10.08',
width: 1920,
height: 1080,
bitrate: 8_000_000,
framerate: 30
});
Why this matters: decoding on the client keeps the server stateless with respect to rendering, so scaling is a bandwidth problem, not a CPU problem.
Real-Time Communication with WebSockets
WebSockets carry three message types:
- CRDT updates — binary Yjs updates, broadcast to all project peers.
- Awareness — cursor positions, selection, playhead.
- Control — join/leave, permissions, playback sync.
sequenceDiagram
participant A as Client A
participant S as Sync Server
participant B as Client B
A->>S: JOIN project-42 (JWT)
S->>A: SNAPSHOT (current CRDT state)
B->>S: JOIN project-42 (JWT)
S->>B: SNAPSHOT
A->>S: CRDT UPDATE (move clip)
S->>B: CRDT UPDATE
B->>S: CRDT UPDATE (trim clip)
S->>A: CRDT UPDATE
Note over A,B: Both replicas converge
Authenticate the WebSocket handshake with a JWT in the query string or Sec-WebSocket-Protocol header. Reject connections for projects the user can’t access.
Media Storage and Streaming Strategies
Store originals in MinIO with content-addressed keys (sha256/<hash>.mp4). Serve via presigned URLs so clients fetch directly, bypassing your API.
| Strategy | Use Case | Trade-off |
|---|---|---|
| Full download | Small clips, offline editing | High initial latency |
| HTTP Range | Scrubbing, seeking | Requires server support |
| HLS/DASH | Adaptive preview | Transcoding overhead |
| Proxy media | Low-res previews | Extra storage |
Generate 540p proxy files on upload using FFmpeg. Editors work against proxies; exports use originals.
Security and Access Control
- Authentication: JWT with short expiry; refresh via HttpOnly cookies.
- Authorization: RBAC per project—
owner,editor,viewer. - Transport: WSS only; HSTS enabled.
- Storage: Presigned URLs expire in minutes; bucket policies deny public reads.
- Audit: Log every CRDT operation with user ID and timestamp.
function authorize(user, projectId, action) {
const role = user.roles[projectId];
if (action === 'read') return ['owner', 'editor', 'viewer'].includes(role);
if (action === 'write') return ['owner', 'editor'].includes(role);
return role === 'owner';
}
Performance Optimization and Scaling
- Binary CRDT encoding: Yjs updates are already binary; never JSON-wrap them.
- Delta compression: Only send changed properties, not whole clips.
- Snapshotting: Persist CRDT snapshots every N operations to bound replay cost.
- Redis pub/sub: Fan out to N server instances horizontally.
- WebCodecs hardware acceleration: Prefer hardware decoders; fall back to software.
- Frame caching: Keep a rolling buffer of decoded frames around the playhead.
| Bottleneck | Mitigation |
|---|---|
| WebSocket fan-out | Redis pub/sub, sharded by project |
| CRDT doc size | Snapshots + garbage collection |
| Media bandwidth | Proxy media + CDN in front of MinIO |
| Client CPU | Web Workers for decode/encode |
Testing and Debugging
- Unit test CRDT logic with two or more
Y.Docinstances applying interleaved operations. - Chaos test WebSockets by dropping connections mid-edit; verify convergence on reconnect.
- Property-based tests (fast-check) to fuzz timeline operations.
- WebCodecs debugging: use
chrome://media-internalsand logEncodedVideoChunkmetadata. - Load test with
k6simulating dozens of editors per project.
// Convergence test
const a = new Y.Doc(), b = new Y.Doc();
Y.applyUpdate(b, Y.encodeStateAsUpdate(a));
a.getArray('timeline').push(['clip-1']);
b.getArray('timeline').push(['clip-2']);
Y.applyUpdate(a, Y.encodeStateAsUpdate(b));
Y.applyUpdate(b, Y.encodeStateAsUpdate(a));
assert.deepEqual(a.getArray('timeline').toArray(), b.getArray('timeline').toArray());
Deployment and Maintenance
Containerize the stack with Docker Compose for small teams, Kubernetes for scale.
services:
api:
build: ./api
environment:
DATABASE_URL: postgres://user:pass@db:5432/editor
REDIS_URL: redis://cache:6379
ports: ["3000:3000"]
db:
image: postgres:16
volumes: ["pgdata:/var/lib/postgresql/data"]
cache:
image: redis:7
minio:
image: minio/minio
command: server /data --console-address ":9001"
volumes: ["miniodata:/data"]
volumes: { pgdata: {}, miniodata: {} }
Maintenance checklist:
- Nightly PostgreSQL backups with point-in-time recovery.
- MinIO bucket versioning and lifecycle rules.
- Monitor WebSocket connection counts, CRDT doc sizes, and decode latency.
- Rolling updates with graceful WebSocket draining.
Conclusion and Future Directions
Self-hosted collaborative video editing is now practical thanks to two converging advances: CRDTs that make concurrent timeline edits trivial to merge, and WebCodecs that push media processing to the client. The architecture in this guide—Yjs for state, WebCodecs for compute, WebSockets for transport, MinIO for storage—scales from a small studio to a distributed team.
Future directions worth exploring: AI-assisted rough cuts running as CRDT operations, WebTransport for lower latency than WebSockets, WASM-based codecs (AV1, HEVC) for broader format support, and end-to-end encrypted CRDTs for zero-trust collaboration.
FAQ
What are CRDTs and why are they suitable for collaborative video editing? CRDTs (Conflict-free Replicated Data Types) are data structures that let multiple users edit shared data concurrently without conflicts. They’re ideal for video editing because they automatically merge changes from different users, ensuring consistency across all clients even with network latency or offline edits.
How does WebCodecs improve video editing performance? WebCodecs provides low-level access to video encoding and decoding directly in the browser, enabling efficient client-side processing. This reduces server load and latency, allowing for real-time previews and edits without round-trips to the server.
Can I use this setup for large video files? Yes, but you need to consider storage and bandwidth. The backend should support chunked uploads and adaptive streaming (e.g., HLS or DASH). WebCodecs can handle large files by decoding only the necessary frames, but client memory and CPU may be limiting factors.
What backend technologies are recommended?
Node.js with Express or Fastify for the API, a WebSocket server (e.g., ws or Socket.IO) for real-time communication, and PostgreSQL for metadata. For media storage, use object storage like MinIO or a file system with chunked uploads.
How do I handle user authentication and permissions? Implement JWT-based authentication and role-based access control (RBAC). Each project can have roles like owner, editor, and viewer. Ensure WebSocket connections are authenticated and authorized for the specific project.