Multiplayer
Node-based UIs are often visual and explorative in nature. For collaborative features that usually means there’s no satisfying “middle-ground” like sparsely syncing state between users, because it would break visual consistency or make collaborative exploration more complicated. Often a full live multiplayer system needs to be put in place. This guide explores best practices for adding realtime multiplayer collaboration to a Vue Flow application with a sync engine such as Yjs .
What are multiplayer applications?
Although realtime collaboration would be the correct term, we refer to it as multiplayer, which was most likely popularized in this context by Figma. Besides being more intuitive, it also does a better job of communicating the commitment to something truly realtime by putting it in the same category as competitive video games.
To figure out what degree of collaboration your application supports, look at how closely it resembles a multiplayer game. Those features likely include:
- No manual saving is required; all users share a world where actions are persisted.
- Any change made by another user is immediately visible to you.
- Not only completed actions but transitive states are synced too (dragging a node, not only dropping it).
- You can see other users (their cursors or viewport positions).
A word of caution
Making multiplayer applications is hard. When users collaboratively edit shared objects, conflicts, delays, connection drops, and concurrent operations are all expected. The hardest challenge is conflict resolution: you need a sync engine that can merge changes coming from the server (or other clients) into the (often optimistic) client state, handle conflicts and disconnects gracefully, and provide a reliable network layer.
The simplest sync engine would be “first come, first served”, with failed requests ignored. Try to handle this by hand and a wide variety of edge cases emerge: networks are slow, clients disconnect, and the order of actions matters.
Local-first and CRDTs
Depending on how far you take conflict and disconnect handling, you quickly land in local-first territory. When your sync engine lets a user stay disconnected for an indefinite amount of time, you have a local-first application. For a good primer, we recommend Ink & Switch’s essay that coined the term.
A CRDT (Conflict-free Replicated Data Type) is a data structure that lets multiple users edit the same data simultaneously on different devices, then automatically merges all changes without conflicts — no central server needed. Rather than storing only the current value, CRDTs keep the operation history on every connected client, so any replica can merge changes into the same final state regardless of the order updates arrive. A couple of popular solutions are Yjs , Automerge , and Loro .
What Vue Flow state should I sync?
Among your first considerations is how you want to sync what parts of your app’s local state.
Ephemeral vs durable
There are many ways to sync state between clients, but they fall into two groups: ephemeral and durable (atomic) changes. Ephemeral changes are not persisted, and neither their consistency nor their correctness is very important — things like cursor or viewport positions, or transient interaction state. Missing some of these updates won’t break anything; after a connection loss a client can just listen for the newest changes and restore any lost state.
Updates to the nodes and edges, on the other hand, should be persistent and consistent. Imagine Alice deletes a node and Bob misses the update — now if Bob moves that node, the application state is inconsistent. This kind of data needs a solution that handles disconnects more aggressively and can discard impossible actions like moving a deleted node.
At the core of a multiplayer Vue Flow app is syncing the nodes and edges. But not all of their
state should be synced: which nodes and edges are selected should differ per client, and some
fields are computed or transient (dragging, resizing, measured). Here’s an overview of what
to sync and what not to.
Nodes
| Field | Durable | Ephemeral | Explanation |
|---|---|---|---|
id | ✅ | ❌ | Important, always needs to be in sync |
type | ✅ | ❌ | Important, always needs to be in sync |
data | ✅ | ❌ | Important, always needs to be in sync |
position | ✅ | ✳️ | Important, includes transient state |
width, height | ✅ | ✳️ | Important, includes transient state |
dragging | ❌ | ✅ | Transient interaction state |
resizing | ❌ | ✅ | Transient interaction state |
selected | ❌ | ❌ | Per-user UI state |
measured | ❌ | ❌ | Computed from the DOM |
Edges
| Field | Durable | Ephemeral | Explanation |
|---|---|---|---|
id | ✅ | ❌ | Important, always needs to be in sync |
type | ✅ | ❌ | Important, always needs to be in sync |
data | ✅ | ❌ | Important, always needs to be in sync |
source | ✅ | ❌ | Important, always needs to be in sync |
target | ✅ | ❌ | Important, always needs to be in sync |
sourceHandle | ✅ | ❌ | Important, always needs to be in sync |
targetHandle | ✅ | ❌ | Important, always needs to be in sync |
selected | ❌ | ❌ | Per-user UI state |
Connections and cursors
Good examples of ephemeral data are connections (the transient part of an edge being created) and cursors.
Sharing each user’s connection status improves visual consistency across clients, but losing it on disconnect doesn’t affect the correctness of the shared data — it’s purely presentational. The same goes for cursors: they help create the immersion of a shared workspace, but they aren’t essential to the app’s integrity. As long as the nodes and edges stay in sync, cursor state can be safely discarded.
Both can be shared as purely ephemeral state. To reduce the frequency of live updates you can debounce them, and smooth other users’ cursor movements with a library like perfect-cursors .
Third-party libraries and services
There’s no one-size-fits-all backend. The choice between CRDT-based local-first libraries (like Yjs and Jazz ) and server-authoritative approaches (like Liveblocks , Supabase , and Convex ) shapes how you build:
- CRDT libraries make offline-first multiplayer easier, with automatic conflict resolution for free. But if your app is also structured around a database, you’ll need to write your own adapters between the CRDT library and the database.
- Server-authoritative solutions are easier to pair with a database and classical application logic, but complicate offline-first support, conflict resolution, and development.
| Name | Architecture | Offline support | Conflict resolution | Notes |
|---|---|---|---|---|
| Yjs | CRDT (local-first) | ✅ Full offline support | Automatic (CRDT) | Collaborative editing, offline-first CRDT for local-first apps. |
| Jazz | CRDT (local-first) | ✅ Full offline support | Automatic (CRDT) | A full, offline-first database solution that handles multiplayer natively. |
| Liveblocks | Server-authoritative, CRDT-like | 🟡 Partial offline support | Uses CRDTs under the hood | Real-time collaboration platform with an open-source backend. |
| Supabase | Server-authoritative | ❌ Requires connection | Manual / RLS policies | Classic SQL-based database with realtime support, but no CRDT (resolve conflicts manually). |
| Convex | Server-authoritative | ❌ Requires connection | Optimistic | TypeScript-first database with real-time queries and a sync engine. |
| Automerge | CRDT (local-first) | ✅ Full offline support | Automatic (CRDT) | The canonical CRDT library; can be paired with a database like Convex. |
| Loro | CRDT (local-first) | ✅ Full offline support | Automatic (CRDT) | A fast CRDT library in Rust and WebAssembly with history tracking and version control. |