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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.