Tl;Dr; ReactJS is faster when Virtual DOM reconciliations are done on a Web Worker thread. Check out the difference at the demo page
A typical ReactJS application consists of two parts - the React library responsible for most of the complex Virtual DOM calculations, and React-Dom that interacts with the browser's DOM to display contents on the screen. Both these are added to the page using script tags and run in the main UI thread.
In a blog post a few weeks ago, I had written about an experiment where I tried to run the React Virtual DOM calculations in a Web worker instead of main UI thread of the browser. I had also run performance measurements to understand the impact of parameters like node count or parallel workers on frame rates.
Recap of previous resultsThe frame rate numbers in themselves were not conclusive from the previous implementation. It was observed that the real benefit of Web Workers only surfaced when there were sufficiently large number of nodes to change. In fact, the performance of Web Workers was worse than the normal React implementation when the node count as small as in a typical for most applications.
Updates and new resultsThe reason that the Web-workers case is slow was due to the time spent passing and processing messages between Web Workers and the main UI thread. I was trying to solve this problem by trying to find an optimal batch size so that the message processing time is much less than actual DOM manipulation. While tweaking the batch size did not yield great benefits, I got a couple of good suggestions from folks on the internet.
- The first suggestion was to use transferable objects instead of using JSON data to pass messages. The DOM manipulation instructions I was passing between the worker and the UI thread did not have a fixed structure. Thus, I would have to implement a custom binary protocol to make this work.
- The second suggestion was to simply use JSON.stringify when passing messages. I guess this is similar to transferable objects, just that in this case, it is a big blob of 8-bit characters. There is also a comment about this by one of the IndexedDB authors.
I wrote an automation script to calculate the frame rates using browser-perf, and here is the chart. The tests were run on Desktop Chrome on a Macbook pro, and a Nexus Android device.
As the number of nodes get to more than 100, the difference is not very visible. To make the difference explicit, here is the same chart with the frame rates in a logarithmic scale when running on desktop chrome.
As you can see from the charts, the React Worker version is at least as fast as, if not faster than the normal version. The difference starts to get more pronounced as the number of nodes increases.
A good experiment should be reproducible, and you can use these instructions to run the tests and collect the information, or simple use Chrome's FPS meter to see the difference in the worker and normal pages.
A real world appWhile it worked well on an articifial app like DBMonster, it is also important to test this idea on typical real world apps. I wrote a todo app that also serves as an example to show the changes needed in a react app to make it work with Web workers. The changes are not many and we basically need to separate React and React-DOM into the worker and main threads respectively.
Browser EventsA web worker does not have access to the browser DOM and hence cannot listen to click or scroll events. Presently, React has an event system with a top level event listener that listens to all events, converts them into synthetic events and sends it over to listeners that we define in the Virtual DOM (in JSX files).
For our webworker case, I re-use this event listener and subscribe to all events. Thus, all events are handled in the main thread, converted to synthetic events and then passed over to the worker. This also means that all the calculations to create synthetic events happens in the main thread. A potential improvement would be passing the raw events over to the worker and calculating synthetic events and bubbling on the worker.
The other issue is about semantics like preventDefault() or stopPropogation(), as also described in the pokedox article. Responding to event in a browser is synchronous while passing messages and getting a result back from a web worker is asynchronous. Thus, a way is needed to determine if we need to prevent default even before the event handler running on a worker can tell us.
At the moment, I simply prevent all default actions, but there are two options here to ensure correct behavior. As vjeux suggests, we could use a pure function that can be serialized and sent to the main UI thread from the worker. Another option would be to prevent the current event and raise another event in case preventDefault is not called.
I am still exploring the options and as other frameworks start offloading work to web workers, I am sure we could come up with a pattern.
My implementation may have some gaps and I would like to try it out on more real world apps. If you have an app suggestion and would like to try it out, I would love to work with you. You can either ping me, or head over to the github repo to send in pull requests !