There's a moment every developer building a multiplayer app chases.
The first time two cursors move across the same canvas,
Then five,
Then ten,
Then someone shares the workspace link with everyone.
That's when reality hits.
Instead of buttery-smooth collaboration, the browser turns into a slideshow. Cursors stutter, clicks feel delayed, and Chrome's Task Manager suddenly looks like it's mining Bitcoin.
While building VisionBoard, a collaborative visual workspace, I ran into exactly this problem. The goal was simple: render dozens of live cursors simultaneously without sacrificing the responsiveness of the rest of the app.
Here's how I got it running at 60 FPS, even with 50 concurrent users.
The real problem wasn't WebSockets—it was React
To make cursor movement feel natural, clients send position updates roughly every 40ms.
That's 25 updates per second.
With 50 users connected:
25×50=1,250 updates per second
At first glance, React's useState feels like the obvious place to store those positions.
TypeScript
function BoardCanvas() {
const [remoteCursors, setRemoteCursors] = useState({});
useWebSocket((packet) => {
setRemoteCursors((prev) => ({
...prev,
[packet.userId]: packet,
}));
});
return (
<div className="canvas">
<Toolbar />
<KanbanBoard />
<RichTextEditors />
<LiveCursorsOverlay cursors={remoteCursors} />
</div>
);
}
Looks harmless.
Until you realize every tiny mouse movement asks React to reconsider rendering:
the toolbar,
every board card,
every rich-text editor,
sidebars,
overlays...
...1,250 times every second.
The browser wasn't struggling to receive cursor data.
It was struggling to render everything that didn't need to change.
Step 1: Separate cursor updates from the rest of the app
The first breakthrough came from treating cursor positions as a completely different category of state.
Instead of putting them inside the main React tree, I moved them into a dedicated Zustand store.
TypeScript
export const useCursorStore = create<CursorState>((set, get) => ({
cursors: {},
applyCursor: (update) => {
const { cursors } = get();
set({
cursors: {
...cursors,
[update.userId]: update,
},
});
},
}));
Now the board itself doesn't care when someone wiggles their mouse.
Only the cursor overlay subscribes to those updates.
TypeScript
const cursors = useCursorStore((s) => s.cursors);
const remoteCursors = useMemo(() => {
return Object.values(cursors).filter(
(c) => c.userId !== currentUserId
);
}, [cursors, currentUserId]);
This single architectural change dramatically reduced unnecessary work.
Instead of the entire application reacting to cursor packets, only one lightweight overlay does.
Step 2: Stop React from re-rendering 49 innocent cursors
There was still another hidden bottleneck.
Imagine User A moves their mouse.
React sees that the remoteCursors array changed.
By default, every <CursorItem /> inside that array becomes a candidate for re-rendering—even if Users B through Z haven't moved at all.
The fix was wrapping each cursor in React.memo with a custom comparison.
TypeScript
const CursorItem = memo(function CursorItem({ cursor }) {
return (
<div
style={{
transform: `translate3d(${cursor.x}px, ${cursor.y}px, 0)`,
}}
>
<CursorSvg color={cursor.userColor} />
</div>
);
}, (prev, next) => {
return (
prev.cursor.x === next.cursor.x &&
prev.cursor.y === next.cursor.y &&
prev.cursor.userId === next.cursor.userId
);
});
Now when one user moves, only that user's cursor component updates.
The other 49 stay untouched.
It's a surprisingly satisfying optimization because it turns rendering into a one-to-one relationship with actual user movement.
Step 3: Let the GPU do the heavy lifting
The biggest visual improvement came from something that wasn't React at all.
It was CSS.
Using top and left forces the browser to recalculate layout repeatedly.
Instead, every cursor moves with:
CSS
transform: translate3d(x, y, 0);
will-change: transform;
Why it matters:
translate3d()pushes movement onto the GPU.will-changetells the browser to keep the element ready for continuous movement.Layout recalculations disappear.
Then I added one more trick.
CSS
transition: transform 120ms cubic-bezier(0.16, 1, 0.3, 1);
Even though network packets arrive at 25Hz, the browser interpolates the movement between packets.
The result feels much closer to native cursor movement than raw packet rendering ever could.
Step 4: Kill ghost cursors before they haunt your app
Real users don't disconnect politely.
They close laptops.
Lose Wi-Fi.
Refresh tabs.
Without cleanup, their cursors would stay on the board forever.
The cursor store periodically removes inactive users.
TypeScript
pruneStale: (maxAgeMs) => {
const { cursors } = get();
const now = Date.now();
const nextCursors = {};
for (const [id, cursor] of Object.entries(cursors)) {
if (now - cursor.lastSeen <= maxAgeMs) {
nextCursors[id] = cursor;
}
}
set({ cursors: nextCursors });
};
It's a small detail, but these cleanup routines prevent long-running sessions from slowly accumulating unnecessary DOM nodes and memory usage.
What actually made the difference
Looking back, none of these optimizations were particularly magical.
The performance came from stacking small wins together.
| Problem | Before | After |
|---|---|---|
| Cursor state | useState |
Zustand |
| Re-render scope | Entire board | Cursor overlay only |
| Cursor updates | Every cursor re-rendered | Only the changed cursor |
| Positioning |
top/left
|
translate3d() |
| Motion | Raw packets | GPU-interpolated transitions |
| Cleanup | Ghost cursors | Automatic pruning |
Each optimization removed a little unnecessary work.
Together, they transformed the experience.
The lesson I'll keep using
Building real-time features taught me something I'll carry into every React project.
Not all state deserves to live inside React's rendering lifecycle.
When data changes dozens of times per second—cursor positions, drag previews, live presence, animations—the goal isn't just managing state.
It's deciding which parts of your UI actually deserve to re-render.
Once I stopped treating every mouse movement like application state and started treating it like a rendering problem, the frame drops disappeared.
And seeing fifty cursors glide smoothly across the board without the browser breaking a sweat made every optimization worth it.
Top comments (0)