React at 60fps: improving scrolling comments in Figma

Over the past few weeks, you may have noticed that comment pins started scrolling more smoothly as you pan around the canvas. We’ve recently improved frame rate per second performance three-fold. Kiko Lam, an engineer on Figma’s collaboration team, shares how she approached this project—investigating the underlying cause of slow performance, challenges with comment implementation, and our results and next steps.

Figma enables closer collaboration between designers and non-designers by tightening the feedback loop. By commenting directly on a file or prototype, teammates have important context, without needing to send files back and forth. 

Since we first introduced Figma, we’ve been making consistent improvements to reach new levels of scale. As more users leave an increasing number of comments on their files, we started to observe performance problems. Knowing that Figma supports teams and organizations of all sizes, we had to do better. So, we kicked off a project to improve the speed at which comments respond when users zoom and pan on the canvas.

React faster, per second

Our primary goal was to render the editor at 60fps. No matter how our users collaborated, or how many comments and threads they created, we wanted to the editor to perform at a speed that could flex to support them.

60fps is much smoother than 15 or 30fps

But first, infrastructure

Before we dive into performance, it’s important to understand a bit about Figma’s technology. Figma is built on an unconventional stack—like our CTO Evan shared, we essentially made “a browser inside a browser.” Our design editor is powered by WebGL and WebAssembly, with some of the user interface implemented in Typescript and React. Unlike most static interfaces built in React, comments are dynamic, and they can pan and zoom as part of the canvas. As you scroll around the canvas, we anchor your comment to something we call a comment pin, which ensures that your feedback stays exactly where you want it.

To do so, we need to get constant viewport updates from our editor. The viewport updates are stored in Redux and retrieved by the comment components. Each comment pin component uses this information to calculate where the comment pins should be rendered on the canvas in relation to the viewport.

Getting to the bottom of slow performance

In order to improve performance on this particular view, we needed to identify what was slowing it down. We used two main tools: Chrome performance tools and React Profiler.

Components constantly re-render

The profile generated from the Chrome performance tools shows that most of the time was spent on JavaScript (JS). About 68ms per frame is spent on JS on a page with 30 comments and only a small port of the computing time per frame is spent on rendering and painting. Scripting refers to JS events and event handlers; rendering and painting have to do with the translation of HTML elements to displayable on-screen elements. It’s promising that most improvement could be done on the JS and React optimization, but we still needed to understand more of what’s happening under the hood of rendering the comment components in React.

Chrome performance tool shows we spent the majority of time on scripting and rendered the comments view at 19fps with 30 comment threads

We used React Profiler to pinpoint which components were actually re-rendering. React profile shows that only about 1.8ms is spent rendering the comments view. This re-rendering is necessary because its content is changing. However, from the React Profile, we observed that a lot of time was consumed rendering many fixed position components like the left panel, toolbar view, and properties panel. But intuitively, only the comment should care about the viewport change, not these fixed components. The biggest inefficiency that creeps in as React applications grow is needlessly re-rendering components, which is exactly what we observed. This was a red flag, and we needed to address it.

React Profiler shows the left panel, toolbar view, properties panel, comments list, and comments view re-rendered with every viewport change
How different components are structured in the Figma editor

We started investigating why the other components were re-rendering when viewport information in the Redux store changed. We found that Redux runs every single middleware and loops through and runs mapsStateToProps for every connected component, each time an action is dispatched. It then passes all of the data down through multiple layers to the comments view. But in our case, the only thing that should need this is the comments view. We had instances where we were passing in anonymous functions to force the components to render over and over again. 

Our approach

To fix the unnecessary re-rendering, we decided to remove viewport information from our Redux store and instead implemented our own event emitter in our React codebase to broadcast this piece of information. We switched over from old components to functional components and, using React Hooks—which enabled us to memorize expensive computation—we now only do them when information changes. By avoiding dispatching an action to update viewport information in Redux, we successfully stopped running mapStateToProps for every connected component and avoided passing all of the data down through multiple layers to the comments view. As a result, we essentially prevented other components that don’t need ViewportInfo from re-rendering.

Better, but not quite there

At this point, we ran the Chrome performance tool and React Profiler again. We saw that the constant re-rendering had stopped and the frame rate of comment view had significantly improved from 15fps to 50fps with 50 comment pins. However, we still weren’t quite at our goal of 60fps. We also observed that performance linearly degrades with the increasing number of comment pins. So, we still had work ahead of us. 

O(n) operation on every viewport change

TJ Pavlu, an engineer on my team, worked with me on further improvements. By observing how the comment pins move on the document level, we noticed that every comment pin performs a transform action when viewport moves. Each of the comment pin components was recomputing its pin position and performing a transform-style action with each viewport change (which you’ll see in the screen recording below). In turn, comments view triggers an 0(n) operation, where n is the number of comment threads as we pan and zoom. This might seem trivial for files with just a few comments, but the more comments there are, the slower the operation.

With every viewport change, each of the comment pin components recomputed its pin position and performed a transform-style action

We came up with the solution to create an overlay container on the canvas and then to position the comment pins statically on this container. From there, we repositioned the overlay container (one computation) using CSS translate instead of doing so with each comment pin (n computations) as the viewport moves (illustrated in the second screen recording). Now, every viewport change triggers an O(1) operation instead of O(n) operation.

Only the overlay parent component recomposes its position on viewport changes

We created this overlay container by creating a box around the most top-left pin and the most bottom-right pin. This means every time a new comment is added, we have to recompute this top-left/bottom-right boundary box. This tradeoff is worth it because a) comments are added less often than panning around the canvas and b) this boundary box calculation happens when the canvas isn’t moving.

Better performance, not perfection

Based on how we scoped the project—achieving 60fps for files with up to 150 comments—it was a success. You can see from the screen recording below that the interaction is much smoother and delivers a better user experience.

Panning is much smoother now

But with performance, the work is never truly done. Moving forward, it'll be an ongoing process of setting new goals and identifying potential bottlenecks. 

Now, we maintain 60fps rendering, no matter how many comments there are on a file

Beyond performance, we improved our React codebase and moved from old components to a new functional components system, while also taking advantage of React hooks. We'll continue to revisit our systems to ensure that Figma is built for scale. 

We’re always working to facilitate better collaboration, and improving performance for scrolling comments helps people work together, faster. If this sounds like the type of project you’d be excited to work on, come build with us! You can check out more about Figma and our open roles here.