A simple mistake in React that ruined Ecthelion’s console performance

retrixe
6 min readJun 18, 2021

This article was originally written in October 2020.

Some of my code for sticking a div to the bottom when it’s scrolled down
Some of my code for sticking a div to the bottom when it’s scrolled down, after all the performance improvements. Taken from https://github.com/retrixe/ecthelion, licensed under Apache-2.0.

Ecthelion is a front-end written using TypeScript, Next.js and React for Octyne. Octyne is a basic process manager I’ve written in Go which runs apps on your server and provides an HTTP REST API to interface with them e.g. modify files in the process working directory, view the console input/output, start or stop, etc. Ecthelion is written to interface with Octyne over the browser, providing a simple and straightforward way to manage your processes from anywhere.

Recently, I refactored Ecthelion significantly since it was derived from another project and was lazily written. It had many problems like lack of proper routing that made navigation painful, huge React components which made development painful and a general lack of polish and refinement. The refactor was overall really great and helped a lot by tidying up and dividing the app into smaller components, it felt much faster, more refined and made adding features much easier for me. However, this blog post is not about the success but a major deficiency of this rewrite :(

A few days afterward, I began to notice Ecthelion’s terminal output console being really really slow when printing many lines of output at once e.g. when in a BungeeCord server handling 60 players, or restarting a Paper server with many plugins. This was even more exaggerated on phone where it would even hang Chrome.

First misstep :/

At first, I was confused as to why this issue was happening. After all, there were no performance issues in the previous version and the console rendering code was identical. The only differences were in word-wrap being added and the console output stored in memory being limited to 650 lines.

  • word-wrap had been added because on smaller screens, longer words would introduce an overflow-y (and disabling the overflow was undesirable, it was preferable to simply wrap the word)
  • The 650 line limit was put because the old version could get terminated by the browser for using too much RAM when thousands of lines had been printed to the console. Having so many lines in the DOM also has performance implications.

At the time, Ecthelion was using 2 ways to render the console to the DOM and allow the scrollbar to stick to the bottom:

  • using flex-box + flex-direction: column-reverse on Chrome
  • using JavaScript to detect the div scroll position and scrolling it down after updating the DOM (causing a forced reflow) on Firefox/IE/EdgeHTML

While on Chrome, I tested render performance when a line was sent from the server using both code-paths.

Sticky scrolling with JavaScript element.scrollTop
Sticky scrolling with column-reverse CSS

I believed that it was the flex-box rendering which was causing a performance issue, because fair enough, without it. console did seem to be somewhat faster (still slow though really). I decided to disable the Chrome-specific CSS method and see if it helped, and it definitely seemed smoother, so I decided that the CSS method was slower and causing a worse user experience.

What went wrong here: The problem was that this comparison was unfair, since the JS method used its own memoized component called ConsoleView. It did not re-render unless the props changed. This meant that the CSS version would obviously be slower, since it might render more than once, while the JS version would only render once. This optimisation could also be applied to the CSS version. I re-ran tests shortly afterwards after realising this by moving the column-reverse based system into its own memoized component, and the CSS version was actually visibly smoother and faster.

However, this failed to resolve the performance issue. It only helped performance a little on Chrome, which used the CSS method, otherwise, we were back to square one. It was still really slow on all browsers. In addition to this, I found even more bugs. Copying text while new text was being rendered was an incredible pain. And the scrollbar would no longer stay in place either (well, actually it did, but the text would just keep flowing for some reason) so it was impossible to scroll back and view output of the past.

I tried a few small micro-optimizations as well, but I didn’t spend too much time fixing this issue. I tried to replace the span inside ConsoleView with a fragment with the hope of simplifying the DOM tree to no avail. I had realized that batched updates would be necessary but this wouldn’t fix the two aforementioned bugs. There was something more sinister at play here. I tried to think of multiple ideas to fix this issue, such as virtualising the console using react-window, allowing the cache to grow till 1000 lines before truncating it to 650 lines (a workaround at best).

The real problem..

Yesterday, I decided to take another stab at this issue. And while looking at the code and trying to analyse where this problem was, I looked at the performance profile closely. Most of the time was being spent within React! I found this pretty weird and decided to benchmark the individual component renders. They took a few milliseconds, which was clearly no problem.

Eventually, I finally realized what the problem was after looking through how the DOM was being updated.

// https://github.com/retrixe/ecthelion/blob/2a56c7bd43c573fee793ce2faefabb9438911522/imports/dashboard/console/consoleView.tsx#L37-L41<div>
{lastEls(props.console.split('\n').map((i, index) => (
<span key={index}>{i}<br /></span>
)), 650)}
</div>

The problem was that when I changed the new version to limit the array of lines to 650, this meant that it was constantly removing the first element and adding a new one at the end. By setting the key of the span to the array index, React, instead of adding a new span to the end of the div and removing the first one, assumed that it had to update all 650 elements in the div, retroactively editing every element with new text.

To explain it in a more simple manner, imagine you have an array [‘a’, ‘b’] in state. If you render it using map() and indexes as the React index, React will receive this:

<span index={0}>a</span>
<span index={1}>b</span>

Now, if you edit the state to [‘b’, ‘c’], React will receive:

<span index={0}>b</span>
<span index={1}>c</span>

The problem here is that React will update the first and the second element to contain b and c instead of removing the first element and adding one at the bottom. This isn’t an issue at all with 2 elements, but with 650 different elements, this completely ruins performance, since it updates 650 elements in the browser instead of simply removing the 1st one and adding one at the bottom.

The reason this happened was because I forgot to stop using array indexes. The old version performed fine because it did not have a limit. It would simply keep adding lines to the array of lines to render to the screen. This meant that the ID was always unique and React would perform smoothly as expected (until it ran out of memory, of course, or rendered too many lines to the browser). When I began to remove the first element from the array, this broke down because the indexes in the array would change, causing React performance to break down. Using array indexes is only suitable when the array indexes will not change for the elements. Removing the first element of the array changed all the indexes, causing a huge performance problem.

This was a nice learning experience for me. I learnt a lot about performance profiling and React profiling in the process, which I generally didn’t have to deal with before. It also illustrates how small mistakes can cause huge issues. Additionally, since the console used useEffect instead of useLayoutEffect, the console would flicker since there was a delay between adding the element to the bottom, and the div scrolling to the bottom. Using useLayoutEffect helped remove this flicker between adding the element and scrolling to the bottom, making it instant. Paired together with batched console updates, this has helped the console performance on Ecthelion become significantly better.

This is my first blog post. Thank you very much for reading, I am actively practicing on my writing skills and I feel that this is something that I enjoyed writing. Feedback is appreciated.

--

--

retrixe

A developer who spends time watching videos, delving into practical topics, reading articles on Wikipedia and playing Minecraft.