17th Jul 2019
Since we are trying to split up the time intensive search results update from the input, we should make our own separate components instead of using the all-in-one Autocomplete from the react-autocomplete library:
Then, inside of SearchResults, we need to trigger the searchEngine.search(searchTerm) asynchronously instead of on the same render as the initial searchTerm update.
My first thought was to use componentDidUpdate inside of SearchResults to make the searchEngine work async because it sounds like that’s a method that runs after an update.
The thought process is that by moving the expensive searchEngine work to componentDidUpdate you are scheduling it for after an update. It would be done on a separate, subsequent execution stack from the initial input update, allowing a paint in between. I theorized that the new update lifecycle would look something like this:
- input renders with the new searchTerm
- input update gets painted in the browser before starting the queued up searchEngine task. Theoretically, the browser would not prioritize the queued up task over painting because the task is not a user interaction event.
- componentDidUpdate runs after the input paint, calculates the search results and initiates a new update with them.
Unfortunately, as you will see, it is a common misconception about componentDidUpdate that it runs after the browser has been updated. Let’s take a look at the performance profile using this method:
See the problem? componentDidUpdate runs on the same stack as the initial keypress event.
It doesn’t run after a paint, so this execution stack is pretty much equally as expensive as before –we just moved the expensive search method to a different section. While this solution doesn’t improve performance, we can still try to understand exactly what part of the React update lifecycle componentDidUpdate falls on.
While componentDidUpdate isn’t queued up and run on a separate execution stack, it does run after React updates component state and commits updated DOM values. Even though these updated DOM values are not painted by the browser yet, they are still reflective of what the updated UI will look like. That said, any DOM lookups that run inside componentDidUpdate will have access to post-update values. As far as your code is concerned, the component did update, you just don’t see it in the browser yet.
That’s why componentDidUpdate is usually the way to go for anyone that has had to do DOM calculations in their component. It is very handy for updating in response to layout changes, such as first seeing how an update would change an element’s position or size and then updating again based on the new layout.
If componentDidUpdate waited for an actual browser paint before running, whenever componentDidUpdate triggers a new update that alters the layout, it could make for a bad user experience because the user might see a flash as the layout changes twice in quick succession.
Side Note (React Hooks): This distinction is also helpful for understanding the new useEffect vs useLayoutEffect hooks. useEffect is what we were trying to accomplish here. It seems to queue up and run its code on a separate execution stack allowing a paint before running. Whereas useLayoutEffect is more like componentDidUpdate and will allow you to run code after DOM updates but before painting those updates on screen.
If componentDidUpdate isn’t an option, how else can we make it asynchronous? There are two well known methods to set up asynchronous callbacks: Promises and setTimeout.
Lets see how that looks in the profiler:
Note: (anonymous) is the Promise callback where we stuck the search. Do you see the problem?
Promise callbacks, while technically async in that they don’t run synchronously, are not called on a subsequent execution stack.
If you look at the profile closely, the callback is placed under the Run Microtasks section since Promise callbacks are considered a microtask. The browser usually checks for and runs microtasks right after it has finished executing the regular stack, kind of like a last second stow away at the end of the current execution.
While this is good to know, it doesn’t solve our problem. We want a new execution stack so that the browser has a chance to paint before starting the new stack.
A-ha! That did it! Notice that there are two execution stacks: Event (keypress) and Timer Fired(searchResults.js:49) and there is a paint between the two stacks (where the blue line is). This is exactly what we theorized would happen! Lets see that beautiful UI in action!
Big improvement… but it is still pretty disappointing. While it has definitely gotten better, there is still significant, noticeable UI lag. Let’s take a closer look at the profile.
Analyzing this particular profile and understanding what’s causing the lag is tricky. There are two especially useful Key Input Interactions that the profiler highlights, each giving you a different performance measurement:
Key Down: length of time from key press to kicking off the associated Event (keypress) handler.
Key Character: length of time from key press to painting an update in the browser.
In this case, the cause for the longer Key Downs is that they are triggered while the main thread is blocked by the expensive setTimeoutCallback. Take for example the last Key Down. It is unfortunately timed right at the start of the setTimeoutCallback, which runs the expensive search method. Which means the Key Down can’t fire its event until the search calculation is done. For a sense of scale, in this profile those search methods are taking around 330 ms –that’s ⅓ of a second. This is a very long time to block the main thread in the world of good UI. For comparison, a fast typer can type a character around every 90ms, so we would want to be well under that.
What’s especially interesting is that last Key Character. Even after its associated Key Down finishes and fires an Event (keypress), the browser doesn’t paint the new searchTerm and runs another setTimeoutCallback instead. That is why that Key Character interaction takes twice as long.
This was, and still is, a big surprise to me. The whole point of moving the search to the setTimeoutCallback is to give the browser a chance to paint before launching the setTimeoutCallback. Yet, there is no paint after that last Event (keypress) (where the blue line is.)
What we can conclude is that we can’t rely on our theories about how the browser chooses queued tasks. It apparently does not always prioritize paints over timeout callbacks. And when your timeout callbacks can take 330ms and several of them can run consecutively before a browser paint, this ends up blocking the thread for far too long and is bound to cause visual lag.
and suddenly, an idea!
Tune in next week for Multi-threading