Multiplayer
Node-based UIs are often used to create applications that are visual and explorative in their nature. For collaborative features, this usually means that there is no satisfying “middle-ground” solution like sparsely synchronising the 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.
In our Collaborative Flow Pro Example, we show a realtime multiplayer collaboration flow using React Flow and Yjs . In this guide, we will explore some best practices for integrating multiplayer collaboration in your React Flow application, focusing mainly on the high-level application logic you need to implement.
We will answer the following questions:
- What are multiplayer applications? A few words about live updates, local first, and CRDTs
- To sync or not to sync? What React Flow state should I sync with the backend?
- Consolidating shared and local state.
- How to handle conflicts when multiple clients try to update the same state?
- How to display the collaboration state to the users?
- What libraries or data structures to use for realtime collaboration?
What are multiplayer applications?
Although realtime collaboration would be the correct term here, we refer to it as multiplayer, which was most likely popularized by Figma. Besides being more intuitive for marketing purposes, it also does a better job of communicating the commitment to creating something truly realtime by putting it in the same category as competitive video games. So to figure out what degree of collaboration your application supports, you just need to look at how closely it resembles a multiplayer game. These features likely include:
- No manual saving is required; all users share a world where actions are persisted.
- Any changes made by other users are immediately visible to you.
- Not only completed actions, but transitive states are synced too (e.g. dragging a node, not only when the node is dropped).
- You can see other users (e.g. their cursors or viewport positions)
A word of caution
Making multiplayer applications is hard! When users are collaboratively editing shared objects, conflicts, delays, connection drops and concurrent operations are expected. The hardest challenge in multiplayer apps is conflict-resolution: you always need some kind of sync engine that is able to merge any changes coming from the server (or other clients) into the (often optimistic) client state. You need to gracefully handle conflicts and disconnects. You need to build a reliable network layer.
The simplest sync engine for multiplayer would be a “first-come-first-served” approach, and failed requests and messages sent being ignored. If you try to manually handle this, you will see a quite vast variety of edge cases emerge. Networks are slow, clients disconnect, and the order of actions matters.
A real sync engine must, capture operations with causal metadata (why the operation was performed), ensure commutativity and idempotence, and reconcile conflicting operations deterministically.
Local-first and CRDTs
Depending on how far you want to take handling conflicts and disconnects, you very quickly land in local-first territory: multi-replica synced data structures that can work completely offline, and automatically upload, fetch, and reconcile their state on reconnection.
If you would like a good primer on this topic we recommend Ink & Switchs’ essay that coined the term.
Local-first multiplayer applications have been trendy lately: many famous multiplayer solutions, like Yjs , and Jazz , use Conflict Free Replicated Data Types (CRDTs) to achieve realtime collaboration.
CRDT-backed structures solve the conflict and disconnects by having an intrinsic notion of mutation and conflict resolution: each update you perform on the CRDT is an algebraically well-defined, conflict-free operation that can be combined with the others independently of when it was applied. With this approach, the client is the source of truth for the data, which is replicated across all the different machines, along with a history of the operations.
What React Flow state should I sync?
Among your first considerations should be how you want to sync what parts of the local state of your app.
Ephemeral vs Durable
While there are many ways to sync state between clients, we can categorize them into two groups: ephemeral and durable (aka atomic) changes. Ephemeral changes are not persisted and neither its consistency nor its correctness is very important for the functionality of the application. These are things like cursor or viewport positions or any transient interaction state. Missing some of these updates will not break anything and in case of a connection loss, a client can just listen for the newest changes and restore any lost functionality.
On the other hand, updates to the nodes & edges should be persistent and consistent! Imagine if Alice deletes a node and Bob misses this update. Now if Bob subsequently moves the node, we would have an inconsistent application state. For this kind of data we have to use a solution that handles disconnects more agressively and is able to discard impossible actions like moving a deleted node.
At the core of a multiplayer React Flow application is syncing the nodes & edges. However
not all parts of the state should be synced. For instance, what nodes & edges are selected
should be different for each client. Also there are some fields that are relevant for the
certain library functions like dragging, resizing or measured.
This is an overview of what parts are recommended to sync and what not.
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 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 |
Unfinished Connections and Cursors
Sharing the transient part of creating edges is possible by sharing each users
connection as an ephemeral
way. The cursors of each user can also be shared as ephemeral state. Just make sure you share
the actual flow coordinates for mouse positions via screenToFlowPosition. You can use a
library like perfect-cursors to smooth
the movements of other users’ cursors. Both can be shared as ephemeral state.
Recommended multiplayer solutions
We experimented with four different multiplayer backend solutions to understand their best use cases for React Flow apps. What we found out, is that there’s no one-size-fits-all solution!: the choice between CRDT-based local-first libraries (like Yjs and Jazz ) and server-authoritative approaches (like Supabase and Convex ) will change the way you approach building multiplayer React Flow applications:
- CRDT libraries, on the other hand, will make your life easier around offline-first multiplayer support, giving you for-free automatic conflict resolution, but in the case where your application is also structured around a database, you will need to implement your own adapters between the CRDT library and the database.
- Server-authoritative solutions will make your life easier around a database and classical application logic, but will complicate your life around offline-first multiplayer support, conflict resolution, and development complexity.
In the table below, you can see them compared:
| Solution | Architecture | Offline Support | Conflict Resolution | Setup Complexity | Best For | React Flow Pro Examples |
|---|---|---|---|---|---|---|
| Yjs | CRDT (Local-first) | ✅ Full offline support | Automatic (CRDT) | Medium | Collaborative editing, offline-first apps | Collaborative Pro Example |
| Jazz | CRDT (Local-first) | ✅ Full offline support | Automatic (CRDT) | Medium | Collaborative editing, offline-first apps | Coming soon… |
| Supabase | Server-authoritative | ❌ Requires connection | Manual/RLS policies | Medium-High | SQL-based apps, existing Supabase users | Coming soon… |
| Convex | Server-authoritative | ❌ Requires connection | Reactive queries | Low-Medium | TypeScript-first, real-time queries | Coming soon… |