Have you ever wondered how tools like Figma or Miro manage to synchronize hundreds of concurrent users seamlessly on a single canvas? The secret lies in a highly-optimized, low-latency real-time transport layer coupled with optimistic UI updates that trick the user into experiencing zero delay.
In this extensive tutorial, we will build a production-ready multiplayer canvas where users can see each other's cursors moving in real-time. Whether you are building a collaborative text editor, an intricate digital whiteboard, or a multiplayer game, the core concepts we cover here will serve as your exact architectural blueprint.
What You Will Build
Before we write code, it is important to visualize the macro architecture. A real-time app relies on decoupled clients interacting through a centralized state broadcaster.
Real-time WebSocket Server
A robust Node.js backend using the native `ws` module to securely broadcast fast-moving coordinate data with sub-10ms latency.
Optimistic UI Canvas
A React canvas that avoids standard rendering bottlenecks to interpolate cursor data smoothly at 60 frames per second.
Zustand Memory Store
A highly optimized client-side state manager preventing unnecessary React tree re-renders during high-frequency data ingestion.
Prerequisites Required: You should have a foundational understanding of React hooks (useEffect, useRef), Node.js state execution, and basic network protocols. Make sure you have Node >v18 installed to utilize modern fetch and crypto primitives without polyfills.
Protocol Comparison: Why WebSockets Over WebRTC or Polling?
Before we dive into the server implementation code, it's critically important to understand why we are choosing WebSockets over alternative real-time web solutions like WebRTC, Server-Sent Events, or long-polling.
| Technology | Latency | Overhead | Bi-directional | Best Used For |
|---|---|---|---|---|
| WebSockets (TCP) | Low (~10-50ms) | Low | Yes | Real-time games, collaborative cursors, interactive chat |
| WebRTC (UDP/TCP) | Ultra-Low (<10ms) | High (STUN/TURN) | Yes (P2P) | Video streaming, VOIP, peer-to-peer heavy data transfers |
| Server-Sent Events | Low | Low | No (Server -> Client) | Stock tickers, live content feeds, one-way notifications |
| HTTP Polling | High (500ms+) | High | No | Legacy system support, infrequent DB syncs that don't need real-time |
While WebRTC offers the absolute lowest latency by bypassing central servers via Peer-to-Peer, setting up STUN/TURN servers introduces massive infrastructure complexity overhead. For coordinate tracking on a web canvas, WebSockets via the standard WebSocket API provide the perfect balance of low-latency throughput and ease-of-deployment.
Project Architecture & Directory Mapping
Let's maintain a strict decoupling between our rendering client and our broadcasting server. Here is the file structure we will be aiming for by the end of this tutorial:
📦 real-time-cursors
┣ 📂 client/
┃ ┣ 📂 src/
┃ ┃ ┣ 📜 App.tsx # The main React application entry point
┃ ┃ ┣ 📜 CursorTracking.tsx # Contains the heavy-lifting coordinate logic
┃ ┃ ┗ 📜 store.ts # Our Zustand state manager
┃ ┗ 📜 package.json
┣ 📂 server/
┃ ┣ 📜 index.ts # The NodeJS express/ws server
┃ ┣ 📜 socketManager.ts # Logic handling broadcast loops and cleanup
┃ ┗ 📜 package.json
┗ 📜 README.mdLet's break this down systematically.
Step 1: Initialize the Central WebSocket Server
First, let's build the brain of our operation. The server's primary job is entirely passive: it must accept socket connections, securely maintain a registry mapping connection IDs to active user sessions, and broadcast inbound coordinate updates to everyone except the sender to prevent echo feedback loops.
We will use the excellent ws library for Node.js, which is significantly more lightweight than frameworks like socket.io.
import { WebSocketServer, WebSocket } from 'ws';
import { v4 as uuidv4 } from 'uuid';
// Bind the WebSocket Server to port 8080
const wss = new WebSocketServer({ port: 8080 });
const clients = new Map<string, WebSocket>();
wss.on('connection', (ws: WebSocket, req) => {
// Generate a unique session ID for the incoming client
const id = uuidv4();
clients.set(id, ws);
console.log(`[Connection] User ${id} joined from ${req.socket.remoteAddress}`);
ws.on('message', (message: string) => {
try {
const data = JSON.parse(message);
// Efficiently broadcast positional data to all OTHER active clients
for (const [clientId, client] of clients.entries()) {
if (clientId !== id && client.readyState === WebSocket.OPEN) {
// Append the sender's ID so the receivers know which cursor to move
client.send(JSON.stringify({ id, ...data }));
}
}
} catch (error) {
console.error('Failed to parse incoming message:', error);
}
});
ws.on('close', () => {
console.log(`[Disconnection] User ${id} left.`);
clients.delete(id);
// Broadcast a purposeful disconnect event so clients can remove the cursor
for (const client of clients.values()) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ type: 'disconnect', id }));
}
}
});
});
console.log('WebSocket broadcaster running on wss://localhost:8080');import { WebSocketServer } from 'ws';
import { v4 as uuidv4 } from 'uuid';
const wss = new WebSocketServer({ port: 8080 });
const clients = new Map();
wss.on('connection', (ws) => {
const id = uuidv4();
clients.set(id, ws);
ws.on('message', (message) => {
const data = JSON.parse(message);
clients.forEach((client, clientId) => {
if (clientId !== id && client.readyState === 1) {
client.send(JSON.stringify({ id, ...data }));
}
});
});
ws.on('close', () => {
clients.delete(id);
clients.forEach((client) => {
if (client.readyState === 1) {
client.send(JSON.stringify({ type: 'disconnect', id }));
}
});
});
});Step 2: Establish an Optimized Client-Side Data Store
In React, updating state via useState on every single mousemove event will obliterate your application's performance. Mouse events fire up to 120 times per second on high-refresh-rate monitors. React's virtual DOM reconciliation simply cannot keep up with that frequency.
We need a state manager that allows us to subscribe to partial state changes completely outside of React's expensive rendering cycle. This is where Zustand shines.
import { create } from "zustand";
// Define the exact shape of our coordinate memory bank
interface CursorState {
cursors: Record<string, { x: number; y: number; color?: string }>;
updateCursor: (id: string, x: number, y: number) => void;
removeCursor: (id: string) => void;
}
export const useCursorStore = create<CursorState>((set) => ({
cursors: {},
// We use shallow state mutations to prevent deep object comparison overhead
updateCursor: (id, x, y) =>
set((state) => ({
cursors: {
...state.cursors,
[id]: { x, y },
},
})),
removeCursor: (id) =>
set((state) => {
const newCursors = { ...state.cursors };
delete newCursors[id];
return { cursors: newCursors };
}),
}));Performance Warning! Do not use React Context API for tracking cursor movements! The frequent updates will cause entire DOM trees enclosed by the Provider to re-render, leading to catastrophic UI stuttering. Zustand solves this by allowing components to bind to transient state slices directly. Learn more about React Context gotchas here.
Step 3: Wiring the Canvas and Sending Optimistic Events
Now, let's tie the network layer to our presentation layer. We'll capture the user's mouse coordinates using a native DOM event listener, and push that data rapidly to our server. We will heavily utilize CSS transform properties because they bypass the browser's layout engine and are accelerated directly by the GPU.
import React, { useEffect, useRef, useState } from "react";
import { useCursorStore } from "./store";
// Initialize WebSocket strictly outside the component to prevent re-connections on render
const ws = new WebSocket("ws://localhost:8080");
export const CursorArea = () => {
const { cursors, updateCursor, removeCursor } = useCursorStore();
const containerRef = useRef<HTMLDivElement>(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
ws.onopen = () => setIsConnected(true);
ws.onclose = () => setIsConnected(false);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === "disconnect") {
removeCursor(data.id);
} else {
updateCursor(data.id, data.x, data.y);
}
};
// Cleanup socket on unmount if necessary
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
};
}, [removeCursor, updateCursor]);
const handleMouseMove = (e: React.MouseEvent) => {
// Optimistic send: Fire it off instantly without awaiting a response
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ x: e.clientX, y: e.clientY }));
}
};
return (
<div
ref={containerRef}
onMouseMove={handleMouseMove}
className="relative w-screen h-screen bg-gray-950 overflow-hidden text-white"
>
<div className="absolute top-4 left-4 text-xs font-mono opacity-50">
Status: {isConnected ? "Live" : "Connecting..."}
</div>
{Object.entries(cursors).map(([id, pos]) => (
<div
key={id}
// The magic of hardware acceleration lies in using 'transform: translate'
// over 'top' and 'left' properties.
className="absolute w-5 h-5 before:content-[''] before:absolute before:w-full before:h-full before:bg-cyan-500 before:rounded-full before:shadow-[0_0_15px_rgba(6,182,212,0.6)] pointer-events-none transition-transform duration-75 ease-linear"
style={{ transform: `translate(${pos.x}px, ${pos.y}px)` }}
/>
))}
</div>
);
};What's Next? Expanding on the Foundation
Congratulations! You've just created a fundamentally sound, real-time collaboration engine capable of scaling comfortably up to hundreds of concurrent users per Node instance.
While this implementation is robust, production applications handle a few extra layers of polish that you can implement as an exercise:
- Spline Interpolation: Instead of jumping point-to-point linearly, you might want to consider using a mathematics library like perfect-freehand to apply bezier curves and Lerp functions to smooth out networking packet losses.
- Presence & Identifying Systems: Inject an authentication layer so you can display active user avatar images and display names next to their corresponding cursors.
- Event Throttling vs Debouncing: Limit excessive WebSockets messages using a recursive
requestAnimationFramepolling loop rather than pureonMouseMovefiring. This can slash outbound network bandwidth by up to 50% without a noticeable loss in visual quality.
By mastering pure WebSockets alongside highly optimized React renders, you open up the doorway to building infinitely scalable real-time systems that feel indistinguishable from highly polished desktop software.