em icon indicating copy to clipboard operation
em copied to clipboard

High CPU usage when scrolling

Open fbmcipher opened this issue 3 months ago • 3 comments

A performance issue I spotted while researching performance impact of will-change. This happens in Chrome too, but it's easier to see in Safari.

Steps to Reproduce

  1. Open em on an iPhone or a simulator.
  2. Attach Safari DevTools, and enable compositing borders to show repaint counters for each element.
  3. Scroll up and down slowly.

Current Behavior

Static elements like the scroll zone and the toolbar excessively while the viewport scrolls. See the number on the scroll zone, for example, which rapidly rises – showing 274+ repaints even though its content hasn't changed at all.

https://github.com/user-attachments/assets/940ecc5b-a830-4b15-ab57-6d1541b16482

This measurably thrashes the main thread, taking up more than 50% of the CPU cycles during a profile of the screen recording above:

Image

Expected Behavior

Elements shouldn't repaint as the user scrolls.

fbmcipher avatar Nov 28 '25 01:11 fbmcipher

This is probably due to the parallax effect in the scroll zone. Can you confirm?

raineorshine avatar Nov 29 '25 03:11 raineorshine

To confirm this, I ran two performance profiles on a physical iPhone 16 (2024):

  • run 1: ScrollZone enabled
  • run 2: ScrollZone returns null, disabling the entire scroll zone and the parallax effect created using useScrollTop() and transform

In both runs, I scrolled the thoughtspace slowly for 15s, both down and up – as a constant variable to keep both runs as similar as possible.

Image
  • Both runs show high CPU usage – an average of 34.5% utilisation
  • The number of repaints is still unusually high, but stayed consistent between both runs (run 1: 1108, run 2: 1167)
    • So we can rule out the parallax effect, as disabling the entire ScrollZone had no impact on repaint count.
  • The number of JavaScript entries is really high (run 1: 30153, run 2: 21644)
    • The drop in JS entries is very likely due to the parallax effect being disabled – though interestingly, CPU load remains constant. So the parallax effect is doing a lot of JS work, but not in a way that's negatively impacting performance.
    • There are still 20-30k events, timers and function calls within 15 seconds – which seems unusual.
    • JavaScript took 46-56% of total CPU usage in the profile.

Still working out what's causing the very high amount of repaints, but the profile does show some immediate culprits re: the high amount of JavaScript entries:

Image

fbmcipher avatar Dec 04 '25 04:12 fbmcipher

Okay, thanks! Great findings.

The only other hook/component that uses useScrollTop is usePositionFixed which has gone through a few refactors such as #2405 which may have inadvertently affected the performance.

Apparently I switched from a ministore to useState in fe50724 which seems bad for performance. I can see that I didn't want to use a throttled value any more, but maybe I should have created a new ministore and updated the DOM directly to keep it out of the React component lifecycle.

Or it could be something else!

raineorshine avatar Dec 06 '25 01:12 raineorshine

Okay, I've spent a fair bit of time looking at these performance issues this week. Here's what I've been able to work out so far.

Firstly, I can rule out useScrollTop as the primary cause of these issues – at least for now. Some early performance tests showed scroll event handlers potentially being responsible for the slow-down (both Safari & Chrome reported this) - so to confirm, I disabled all scroll event handlers across the app.

Of course, this breaks a few things (including the lazy-loading of thoughts as the user scrolls). Even after removing all scroll event handlers, the performance issues persisted – which I noticed when expanding large thought trees.

I tested this with two production builds of em, both with all scroll event handlers disabled:

  • A production build of em as of main
  • A production build of em as of commit 09dedc24 (from December 2024, hereby named dec-2024)

dec-2024 was much faster when expanding large thought trees. So while scrolling might still contribute to the observed performance issues, there's clearly something deeper going on – and seemingly at the level of loading and rendering thoughts rather than scrolling itself.

If that were the case, it would certainly explain the churn during scrolling in our earlier tests. Since more thoughts are loaded in as the user scrolls, the performance issues we're seeing would make sense if the issue were at the level of loading/rendering thoughts in the LayoutTree.

To get more data, I performed a new test.

Test 2

All of these tests were performed on desktop Chrome, as its profiler goes into significantly more detail than Safari's profiler.

Test: Set up the thoughtspace with the entire em branding dump imported. Record performance profiles in Chrome on both builds when expanding the large "em branding dump" thought.

https://github.com/user-attachments/assets/9c9c1b34-b91c-4173-83e9-b499d2f31477

Builds: main, dec-2024

Results: The expansion takes much longer in main than it does in dec-2024. Investigating the profiler shows significantly more work being done in main than in dec-2024:

Image

A lot of noise/info here – but to summarize: it takes 93ms to render all of the thoughts in main, compared to 35ms in dec-2024, around 2.6x slower. I'm testing on a powerful Mac – on mobile you'd see even worse performance.

Zooming in more closely to see what is actually happening during that render phase in main, the problem begins to become clearer:

Image

React is repeatedly re-entering the commit phase. Each commit re-runs layout and passive effects. One of these effects is updating state or otherwise causing another commit to be triggered. This causes effects to run again. This creates a feedback loop during the mounting phase.

Tracking this through the call stack in the profiler strongly suggests the loop originates from a requestAnimationFrame created by useLayoutAnimationFrameEffect. The circled regions in the image below show the actual requestAnimationFrame callbacks being executed. You can see the amount of overhead caused by commits and effects being run. We don't see anything like this at all in dec-2024.

Image

Since LayoutTree re-renders as thoughts load in while the user scrolls, this would explain the extreme slow-down and CPU load we see while scrolling.

It's strongly likely that the issue lies in one of the following files where useLayoutAnimationFrameEffect is used.

https://github.com/cybersemics/em/blob/66d5f3a8d8baa763e40e4ee7bd3c090cd2bb9a53/src/components/StaticThought.tsx#L130

https://github.com/cybersemics/em/blob/66d5f3a8d8baa763e40e4ee7bd3c090cd2bb9a53/src/components/VirtualThought.tsx#L179-L193

https://github.com/cybersemics/em/blob/66d5f3a8d8baa763e40e4ee7bd3c090cd2bb9a53/src/components/Editable/useMultiline.ts#L41-L43

Thoughts @raineorshine? I ran out of time for the week before I could investigate further, but I wonder if any of this information raises anything for you.

fbmcipher avatar Dec 14 '25 05:12 fbmcipher