Synchronizing a Collaborative Visual Editor's XState Machine with a Go-Gin WebSocket Backend


The initial proof-of-concept for our internal workflow tool was deceptive in its simplicity. A drag-and-drop interface for connecting nodes seemed straightforward. The complexity escalated when the requirement shifted to real-time, multi-user collaboration. Our first attempt, built on a series of React state hooks and event emitters, quickly devolved into a nightmare of race conditions and inconsistent UI states. One user dragging a node would frequently conflict with another user creating a new connection, leaving clients in desynchronized, broken states. It became clear that managing state with boolean flags like isDragging or isConnecting was a fragile, unscalable approach. This was not a simple state management problem; it was a distributed systems problem manifesting in the browser.

The core pain point was the lack of a formal, predictable model for the editor’s behavior. We needed a system that could deterministically handle a sequence of user inputs and server events, regardless of their timing. This led us directly to statecharts and XState. A state machine provides a robust framework for defining all possible states of the editor (idle, dragging, connecting, etc.) and the explicit transitions between them. It eliminates impossible states and makes the application logic verifiable.

For the backend, the choice was Go. The requirement was to maintain thousands of persistent WebSocket connections with minimal resource overhead. Go’s concurrency model, with its lightweight goroutines, is tailor-made for this kind of I/O-bound task. A single Go process can handle a massive number of concurrent connections far more efficiently than a thread-per-connection model. We chose the Gin framework not for its extensive feature set, but for its minimalist approach and high-performance router, which was all we needed to bootstrap our WebSocket endpoint.

Finally, the UI required dynamic styling. Nodes could have different colors, states (selected, errored), and custom labels. Hard-coding these styles in CSS files was impractical. We considered CSS-in-JS solutions, but the runtime overhead and complexity felt disproportionate. UnoCSS, with its on-demand, atomic CSS engine, presented a more pragmatic solution. It could generate the precise utilities needed based on class names derived directly from our state machine’s context, offering extreme flexibility without the bloat.

The architecture was thus decided: An XState machine on the frontend as the single source of truth for UI state, a Go-Gin application managing WebSocket communication and broadcasting state changes, and UnoCSS handling the dynamic visual representation.

The Go WebSocket Hub: A Concurrency-Safe Broadcaster

The backend’s primary responsibility is to accept WebSocket connections and broadcast incoming messages to all other connected clients. It’s a classic hub-and-spoke model. The implementation must be concurrency-safe, as multiple clients will be writing messages and connecting/disconnecting simultaneously.

We started by defining the core Hub struct. It contains channels for registering, unregistering, and broadcasting messages, along with a map to hold the active client connections. Using channels is idiomatic Go for synchronizing access to shared resources like the clients map, preventing race conditions without explicit locks.

// pkg/hub/hub.go
package hub

import (
	"log"
	"sync"
)

// Message defines the structure for WebSocket messages exchanged between client and server.
// The raw []byte data is used to avoid constant JSON marshal/unmarshal on the server,
// passing it through directly. The sender's client pointer is used to avoid
// broadcasting the message back to its origin.
type Message struct {
	Data   []byte
	Sender *Client
}

// Hub maintains the set of active clients and broadcasts messages to them.
// It is the central component for managing WebSocket connections.
type Hub struct {
	// A mutex is used here to protect access to the clients map, specifically for
	// the edge case of checking if a client exists before adding. While channels
	// handle the main flow, a lock is simpler for this one-off check.
	mu sync.RWMutex

	// Registered clients.
	clients map[*Client]bool

	// Inbound messages from the clients.
	broadcast chan *Message

	// Register requests from the clients.
	register chan *Client

	// Unregister requests from clients.
	unregister chan *Client
}

func NewHub() *Hub {
	return &Hub{
		broadcast:  make(chan *Message),
		register:   make(chan *Client),
		unregister: make(chan *Client),
		clients:    make(map[*Client]bool),
	}
}

// Run starts the hub's event loop. This method should be run in a separate goroutine.
// It listens on its channels and acts as a single point of control, serializing
// all operations (register, unregister, broadcast) to prevent race conditions.
func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			h.mu.Lock()
			// Only add the client if it's not already present.
			if _, ok := h.clients[client]; !ok {
				h.clients[client] = true
				log.Printf("Client registered: %s, total clients: %d", client.conn.RemoteAddr(), len(h.clients))
			}
			h.mu.Unlock()

		case client := <-h.unregister:
			h.mu.Lock()
			// Ensure the client exists before trying to delete and close its channel.
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
				log.Printf("Client unregistered: %s, total clients: %d", client.conn.RemoteAddr(), len(h.clients))
			}
			h.mu.Unlock()

		case message := <-h.broadcast:
			h.mu.RLock()
			for client := range h.clients {
				// The core logic: don't send the message back to the sender.
				if client != message.Sender {
					select {
					case client.send <- message.Data:
						// Message successfully sent to the client's send channel.
					default:
						// If the client's send channel is full, it indicates a slow consumer.
						// We close the connection to prevent the hub from blocking.
						log.Printf("Client send channel full, disconnecting: %s", client.conn.RemoteAddr())
						close(client.send)
						delete(h.clients, client)
					}
				}
			}
			h.mu.RUnlock()
		}
	}
}

The Client struct wraps a gorilla/websocket connection and includes a buffered channel send for outbound messages. This decouples the hub’s broadcast loop from the network write operation for each client. If a client’s network connection is slow, it won’t block the entire hub from broadcasting to other clients.

// pkg/hub/client.go
package hub

import (
	"log"
	"time"

	"github.com/gorilla/websocket"
)

const (
	// Time allowed to write a message to the peer.
	writeWait = 10 * time.Second
	// Time allowed to read the next pong message from the peer.
	pongWait = 60 * time.Second
	// Send pings to peer with this period. Must be less than pongWait.
	pingPeriod = (pongWait * 9) / 10
	// Maximum message size allowed from peer.
	maxMessageSize = 1024 * 4 // 4KB
)

// Client is a middleman between the websocket connection and the hub.
type Client struct {
	hub  *Hub
	conn *websocket.Conn
	// Buffered channel of outbound messages.
	send chan []byte
}

// readPump pumps messages from the websocket connection to the hub.
func (c *Client) readPump() {
	defer func() {
		c.hub.unregister <- c
		c.conn.Close()
	}()
	c.conn.SetReadLimit(maxMessageSize)
	c.conn.SetReadDeadline(time.Now().Add(pongWait))
	c.conn.SetPongHandler(func(string) error {
		c.conn.SetReadDeadline(time.Now().Add(pongWait))
		return nil
	})

	for {
		_, messageData, err := c.conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
				log.Printf("error: %v", err)
			}
			break
		}
		// The message is wrapped with the sender's identity for the hub.
		message := &Message{Data: messageData, Sender: c}
		c.hub.broadcast <- message
	}
}

// writePump pumps messages from the hub to the websocket connection.
func (c *Client) writePump() {
	ticker := time.NewTicker(pingPeriod)
	defer func() {
		ticker.Stop()
		c.conn.Close()
	}()

	for {
		select {
		case message, ok := <-c.send:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if !ok {
				// The hub closed the channel.
				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
				return
			}

			w, err := c.conn.NextWriter(websocket.TextMessage)
			if err != nil {
				return
			}
			w.Write(message)

			// Add queued chat messages to the current websocket message.
			n := len(c.send)
			for i := 0; i < n; i++ {
				w.Write(<-c.send)
			}

			if err := w.Close(); err != nil {
				return
			}
		case <-ticker.C:
			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
				return
			}
		}
	}
}

The Gin handler is minimal. It upgrades the HTTP connection to a WebSocket connection and creates a Client instance, registering it with the hub.

// main.go
package main

import (
	"log"
	"net/http"
	"project/pkg/hub"

	"github.comcom/gin-gonic/gin"
	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	ReadBufferSize:  1024,
	WriteBufferSize: 1024,
	CheckOrigin: func(r *http.Request) bool {
		// A real-world project would have a proper origin check.
		return true
	},
}

func serveWs(hub *hub.Hub, w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		log.Println(err)
		return
	}
	client := &hub.Client{Hub: hub, Conn: conn, Send: make(chan []byte, 256)}
	client.Hub.Register(client) // Assume Register is a public method on Hub

	go client.WritePump()
	go client.ReadPump()
}


func main() {
	hubInstance := hub.NewHub()
	go hubInstance.Run()

	router := gin.Default()
	router.GET("/ws", func(c *gin.Context) {
		serveWs(hubInstance, c.Writer, c.Request)
	})
	
	log.Println("Server starting on :8080")
	router.Run(":8080")
}

// Add these to the Hub struct for public access from main
func (h *Hub) Register(client *Client) {
	h.register <- client
}

This backend setup is robust. It correctly handles concurrent connections, provides backpressure for slow clients, and uses keep-alives (ping/pong) to clean up dead connections. It does one thing and does it well: reliably broadcasting messages.

The XState Machine: Formalizing UI Logic

On the frontend, the first step was to model the entire editor’s behavior as a finite state machine. This is where XState shines. We defined a machine that captured not just the state of data (nodes, edges) but the state of user interaction.

Here is a simplified version of the machine’s core definition:

// editorMachine.ts
import { createMachine, assign } from 'xstate';

// Define the context (the "extended state") of our machine
interface EditorContext {
  nodes: Map<string, { id: string; x: number; y: number; text: string }>;
  edges: Map<string, { id: string; source: string; target: string }>;
  draggingNodeId: string | null;
  dragOffset: { x: number; y: number };
}

// Define the events that can be sent to the machine
type EditorEvent =
  | { type: 'NODE.ADD'; node: EditorContext['nodes'] extends Map<any, infer V> ? V : never }
  | { type: 'NODE.DRAG_START'; nodeId: string; x: number; y: number }
  | { type: 'NODE.DRAG'; x: number; y: number }
  | { type: 'NODE.DRAG_END' }
  | { type: 'SYNC.STATE'; state: { nodes: any, edges: any }}; // From WebSocket


export const editorMachine = createMachine<EditorContext, EditorEvent>({
  id: 'visualEditor',
  initial: 'idle',
  context: {
    nodes: new Map(),
    edges: new Map(),
    draggingNodeId: null,
    dragOffset: { x: 0, y: 0 },
  },
  states: {
    idle: {
      on: {
        'NODE.DRAG_START': {
          target: 'dragging',
          actions: assign({
            draggingNodeId: (_, event) => event.nodeId,
            dragOffset: (context, event) => {
              const node = context.nodes.get(event.nodeId);
              return node ? { x: event.x - node.x, y: event.y - node.y } : { x: 0, y: 0 };
            },
          }),
        },
      },
    },
    dragging: {
      // Every drag event will trigger an action to update the node position locally
      // and send the new state to the WebSocket backend. This action is "invoked"
      // and will be defined later in the component logic.
      on: {
        'NODE.DRAG': {
          actions: [
            assign({
              nodes: (context, event) => {
                if (!context.draggingNodeId) return context.nodes;
                const newNodes = new Map(context.nodes);
                const node = newNodes.get(context.draggingNodeId);
                if (node) {
                  node.x = event.x - context.dragOffset.x;
                  node.y = event.y - context.dragOffset.y;
                }
                return newNodes;
              },
            }),
            'sendUpdateToSocket', // This is a named action we implement in our component
          ],
        },
        'NODE.DRAG_END': {
          target: 'idle',
          actions: assign({
            draggingNodeId: null,
          }),
        },
      },
    },
  },
  // Global event handlers
  on: {
    'NODE.ADD': {
      actions: assign({
        nodes: (context, event) => {
          const newNodes = new Map(context.nodes);
          newNodes.set(event.node.id, event.node);
          return newNodes;
        },
      }),
    },
    // This is the critical part for collaboration.
    // When a SYNC.STATE event comes from the WebSocket,
    // we completely replace our local state with the server's truth.
    'SYNC.STATE': {
      actions: assign({
        nodes: (_, event) => new Map(Object.entries(event.state.nodes)),
        edges: (_, event) => new Map(Object.entries(event.state.edges)),
      }),
    },
  },
});

The state machine diagram helps visualize the flow:

stateDiagram-v2
    [*] --> idle
    idle --> dragging: NODE.DRAG_START
    dragging --> idle: NODE.DRAG_END
    dragging: on NODE.DRAG

This formal structure is powerful. It is now impossible for the UI to be in a state where it thinks it’s dragging two nodes at once. The transition from idle to dragging is atomic and gated by the NODE.DRAG_START event.

The real challenge was integrating the WebSocket communication. We did this by defining services and actions within our React component that uses the machine. An action (sendUpdateToSocket) pushes local changes to the Go backend, while a listener for WebSocket messages dispatches events (SYNC.STATE) back into the machine. A pitfall here is creating feedback loops. Our Go backend correctly does not broadcast a message back to its sender, which is the first line of defense. On the client, we could also add a check to see if the incoming state from the server is different from the current context before applying it, although our current “last-write-wins” model is simpler to implement.

Here’s the React component binding everything together.

// EditorComponent.tsx
import React, { useEffect, useRef } from 'react';
import { useMachine } from '@xstate/react';
import { editorMachine } from './editorMachine';
import useWebSocket from 'react-use-websocket';

const WS_URL = 'ws://localhost:8080/ws';

// This is a simplified serialization for demonstration.
// A real app would use a more robust format.
const serializeMap = (map) => Object.fromEntries(map);

export const EditorComponent = () => {
  const { sendJsonMessage, lastJsonMessage } = useWebSocket(WS_URL, {
    share: true, // Important for single connection across components
  });

  const [state, send] = useMachine(editorMachine, {
    actions: {
      sendUpdateToSocket: (context) => {
        // We only send updates when the user is actively doing something.
        // This prevents broadcasting state received from the server back to it.
        if (state.matches('dragging')) {
          sendJsonMessage({
            type: 'STATE_UPDATE',
            payload: {
              nodes: serializeMap(context.nodes),
              edges: serializeMap(context.edges),
            },
          });
        }
      },
    },
  });
  
  // This effect listens for messages from the WebSocket and syncs the machine.
  useEffect(() => {
    if (lastJsonMessage && lastJsonMessage.type === 'STATE_UPDATE') {
      // A common mistake is to fail to parse the incoming data correctly.
      // Ensure the payload structure matches what the machine expects.
      try {
        const nodes = Object.entries(lastJsonMessage.payload.nodes);
        const edges = Object.entries(lastJsonMessage.payload.edges);

        send({
          type: 'SYNC.STATE',
          state: { nodes, edges },
        });
      } catch (e) {
        console.error("Failed to parse incoming state from WebSocket", e);
      }
    }
  }, [lastJsonMessage, send]);
  
  // Handlers for mouse events
  const handleMouseDown = (e, nodeId) => {
    send({ type: 'NODE.DRAG_START', nodeId, x: e.clientX, y: e.clientY });
  };
  
  const handleMouseMove = (e) => {
    if (state.matches('dragging')) {
      send({ type: 'NODE.DRAG', x: e.clientX, y: e.clientY });
    }
  };
  
  const handleMouseUp = () => {
    if (state.matches('dragging')) {
      send({ type: 'NODE.DRAG_END' });
    }
  };
  
  return (
    <div
      className="w-full h-screen bg-gray-800 text-white relative"
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
    >
      {Array.from(state.context.nodes.values()).map(node => (
        <div
          key={node.id}
          onMouseDown={(e) => handleMouseDown(e, node.id)}
          className={`absolute p-4 rounded cursor-pointer select-none
            ${state.context.draggingNodeId === node.id ? 'bg-blue-500 ring-2' : 'bg-gray-600'}
            hover:ring-2 ring-blue-400
            transition-all duration-75
          `}
          style={{ transform: `translate(${node.x}px, ${node.y}px)` }}
        >
          {node.text}
        </div>
      ))}
    </div>
  );
};

Dynamic Styling with UnoCSS

The final piece was the UI rendering, specifically the dynamic styles. With UnoCSS configured in runtime mode, we could construct class names directly from the state machine’s context.

For example, if a node had a user-defined color property in its state context (node.color = 'red-500'), we could render it like this:

// Inside the component's render method
<div
  className={`
    absolute p-4 rounded 
    bg-${node.color} 
    border-2 
    [border-color:theme(colors.${node.borderColor})]
  `}
  // ...
>
  {node.text}
</div>

UnoCSS’s runtime engine would see these classes, generate the corresponding CSS on the fly, and inject it into the DOM. This is far more direct than managing style objects or conditional class maps. A key lesson was that for this to be performant, we needed to use the runtime engine, not the build-time generator, as the class names were not known ahead of time. The performance impact was negligible for our use case, as the engine is highly optimized.

This approach made styling completely data-driven. A change in the XState context would automatically propagate to a new class name, which UnoCSS would then realize as a style update. The styling logic was no longer separate from the application state; it was a direct reflection of it.

The final result is a highly predictable, maintainable, and collaborative editor. Adding new functionality, like an “editing text” mode, is now a systematic process: add an editing state to the machine, define the entry/exit transitions (DOUBLE_CLICK, BLUR), and implement the corresponding UI rendering. The collaborative aspect works automatically because the core sendUpdateToSocket and SYNC.STATE mechanisms are already in place.

However, this implementation is not without its limitations. The “last-write-wins” approach to state synchronization is naive and would fail in high-latency environments or with complex, overlapping edits. A production system would require a more sophisticated conflict resolution strategy, such as Operational Transformation (OT) or Conflict-free Replicated Data Types (CRDTs). Furthermore, the Go hub is a single point of failure and a bottleneck for scalability. To support a larger number of users, this would need to be scaled horizontally, with instances coordinating through a message bus like Redis Pub/Sub or NATS. The current state is also ephemeral; integrating a persistent database on the Go backend to save and load workflow states would be the next logical step toward a production-ready system.


  TOC