Collaborative Workflow Editing#
Dify's workflow editor supports real-time multi-user collaboration, letting multiple engineers edit the same workflow concurrently without conflicting writes. The feature is gated by the enable_collaboration_mode system flag and is implemented entirely in the frontend under web/app/components/workflow/collaboration/.
Architecture Overview#
The collaboration layer has four logical roles:
| Layer | File | Responsibility |
|---|---|---|
| Entry hook | hooks/use-collaboration.ts | React hook; manages lifecycle, exposes state to components |
| Orchestrator | core/collaboration-manager.ts | Singleton; owns the LoroDoc, UndoManager, subscriptions, and event fan-out |
| CRDT transport | core/crdt-provider.ts | Bridges local CRDT mutations → graph_event socket events, and inbound graph_update → doc.import() |
| Socket client | core/websocket-manager.ts | WebSocketClient singleton; per-app Socket.io connections, emitWithAuthGuard helper |
React component
└─ useCollaboration() ← use-collaboration.ts
└─ CollaborationManager ← collaboration-manager.ts
├─ LoroDoc (CRDT)
├─ UndoManager
└─ CRDTProvider ← crdt-provider.ts
└─ WebSocketClient ← websocket-manager.ts
CRDT Data Model#
CollaborationManager.connect() initialises a LoroDoc with two top-level containers :
nodesMap—LoroMapkeyed by node ID; each value is a nestedLoroMapholding node properties and adatasub-map. List-typed fields (variables,prompt_template,parameters) are stored asLoroList.edgesMap—LoroMapkeyed by edge ID; values are plain records .
Private _-prefixed data keys and the selected flag are excluded from CRDT sync to prevent UI-only state from dirtying the document .
Update Flow#
Local → remote: setNodes() / setEdges() call syncNodes() / syncEdges(), then doc.commit(). CRDTProvider subscribes to the LoroDoc and, on any local event, exports an incremental update and sends it via graph_event .
Remote → local: The graph_update socket event delivers a Uint8Array; CRDTProvider calls doc.import(data) . The nodesMap and edgesMap subscriptions fire only when event.by === 'import', schedule a requestAnimationFrame, and call ReactFlow's native setters directly to avoid re-triggering the collaboration path .
Leader Election#
The server assigns a single leader per session via the status socket event . The leader is the source of truth: it seeds the CRDT from the ReactFlow graph on first connection and broadcasts a full doc.export({ mode: 'snapshot' }) snapshot when followers request a resync (graph_resync_request) . Followers that join mid-session emit a graph_resync_request to pull the current snapshot .
Leader selection uses two-tier logic that prioritizes visible tabs — those whose graph_active flag is true — so the leader's canvas reflects the latest CRDT state. When a browser hides a tab, its requestAnimationFrame loop pauses and the React Flow canvas freezes; CRDT updates imported during that time queue but do not apply. If a hidden tab were to remain leader, its draft persistence would snapshot stale graph data and overwrite other collaborators' edits. To prevent this, the frontend emits a graph_view_state event on every visibilitychange, carrying the new visibility state and a monotonically increasing sequence number. The backend atomically updates the graph_active session flag under a Redis lock and demotes the current leader if it just went hidden, promoting a visible tab to leader when one exists. Tier 1 candidate selection filters sessions by graph_active=True; tier 2 (fallback) accepts any active session so a room is never leaderless. The backend also re-routes sync_request events away from a connected-but-hidden leader, preferring the requester whose canvas always contains its own edits.
Undo / Redo#
A UndoManager is created at connect time with a 100-step stack and a 500 ms merge window . Undo/redo sets isUndoRedoInProgress = true to suppress re-entry via the setNodes/setEdges path, then applies the CRDT result back to ReactFlow in a requestAnimationFrame .
Draft Persistence#
The backend persists the workflow draft only via the elected leader session. When the frontend requests a save, the backend routes the sync_request event to the leader using socketio.call(), which enables acknowledgement and a 5-second timeout. The leader imports the provided graph snapshot into its CRDT doc, saves from its own React Flow canvas, and acknowledges success with the hash and updatedAt fields. If the leader doesn't respond within the timeout, the backend returns an error; if the leader is hidden (graph_active=False), the backend may demote it and re-route the save to a visible session or the requester.
The workflow GET API response (/console/api/apps/{app_id}/workflows/draft) includes an _is_collaborative boolean field indicating whether the workflow is currently in collaborative mode. Clients use this to conditionally enable collaboration logic.
Presence & Cursors#
Beyond graph edits, the collaboration layer tracks:
- Cursor positions — throttled mouse events via
mouse_movecollaboration events; positions cleaned up when a user goes offline . - Node panel presence — which client has a node's side-panel open, keyed by
clientIdso a user appears on at most one node at a time . - Online users — provided by the
online_userssocket event; includesuser_id,username,avatar, andsid.
Collaboration update event types are enumerated in types/collaboration.ts, including mouse_move, node_panel_presence, sync_request, graph_resync_request, graph_view_state, workflow_restore_intent, and workflow_history_action.
WebAssembly / Build Configuration#
loro-crdt ships a WebAssembly loader by default. The Vite config aliases bare loro-crdt imports to loro-crdt/base64, which inlines the WASM as a Base64 string and avoids the browser restriction that blocks WebAssembly.compile on the main thread for buffers > 4 KB. Next.js lists loro-crdt as a serverExternalPackages entry to prevent it from being bundled server-side .
Key Files#
| File | Purpose |
|---|---|
collaboration/core/collaboration-manager.ts | Main orchestrator — CRDT state, sync, undo, presence |
collaboration/core/crdt-provider.ts | CRDT ↔ socket bridge |
collaboration/core/websocket-manager.ts | Socket.io connection pool |
collaboration/hooks/use-collaboration.ts | React hook entry point |
collaboration/types/collaboration.ts | Shared TypeScript types |
web/vite.config.ts | loro-crdt/base64 alias |
web/next.config.ts | serverExternalPackages: ['loro-crdt'] |