Documentsdify
Collaborative Workflow Editing
Collaborative Workflow Editing
Type
Topic
Status
Published
Created
Jul 8, 2026
Updated
Jul 20, 2026
Created by
Dosu Bot
Updated by
Dosu Bot

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:

LayerFileResponsibility
Entry hookhooks/use-collaboration.tsReact hook; manages lifecycle, exposes state to components
Orchestratorcore/collaboration-manager.tsSingleton; owns the LoroDoc, UndoManager, subscriptions, and event fan-out
CRDT transportcore/crdt-provider.tsBridges local CRDT mutations → graph_event socket events, and inbound graph_updatedoc.import()
Socket clientcore/websocket-manager.tsWebSocketClient 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 :

  • nodesMapLoroMap keyed by node ID; each value is a nested LoroMap holding node properties and a data sub-map. List-typed fields (variables, prompt_template, parameters) are stored as LoroList .
  • edgesMapLoroMap keyed 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_move collaboration events; positions cleaned up when a user goes offline .
  • Node panel presence — which client has a node's side-panel open, keyed by clientId so a user appears on at most one node at a time .
  • Online users — provided by the online_users socket event; includes user_id, username, avatar, and sid .

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#

FilePurpose
collaboration/core/collaboration-manager.tsMain orchestrator — CRDT state, sync, undo, presence
collaboration/core/crdt-provider.tsCRDT ↔ socket bridge
collaboration/core/websocket-manager.tsSocket.io connection pool
collaboration/hooks/use-collaboration.tsReact hook entry point
collaboration/types/collaboration.tsShared TypeScript types
web/vite.config.tsloro-crdt/base64 alias
web/next.config.tsserverExternalPackages: ['loro-crdt']
Collaborative Workflow Editing | Dosu