close

DEV Community

Cover image for How I Rendered 50 Live Cursors at 60 FPS in React Without Crashing the DOM
Ariyo Aresa
Ariyo Aresa

Posted on

How I Rendered 50 Live Cursors at 60 FPS in React Without Crashing the DOM

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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,
      },
    });
  },
}));
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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
  );
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Why it matters:

  • translate3d() pushes movement onto the GPU.

  • will-change tells 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);
Enter fullscreen mode Exit fullscreen mode

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 });
};
Enter fullscreen mode Exit fullscreen mode

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)