Recently I’ve been writing about Web Workers and various options developers have for leveraging them in their applications. For those unfamiliar, Web Workers allow you to create a separate thread for execution in a web browser. Over recent times we’ve seen a growth in Web Worker libraries such as greenlet, Workerize and Comlink to name a few.
One thing that’s been swirling around in my head is the question around what is the tradeoff of using a Web Worker? They are great because we can leave the main thread for rendering and responding to user interactions, but at what cost? The purpose of this post is to examine empirically where Web Workers make sense and where they might improve an application.
Examining Web Worker Performance
I set out trying to benchmark the performance of Web Workers. This data was collected using code I wrote which can be found at here. All the performance numbers specified are on my Dell XPS, Intel Core i7-4500 CPU @ 1.80GHz, 8GB of RAM, running Xubuntu. References to Chrome are version 66 and for Firefox version 59. To preface there is some possibility that numbers are slightly skewed due to garbage collection which is automated by the browser.
At a high level creation and termination of Web Workers is relatively cost free depending on tolerance for main thread worker.
- Creation normally comes in at around sub 1ms on Chrome
- Termination is sub 0.5ms on Chrome
The real cost of Web Workers comes from the transfer of a data from the main thread to the Web Worker (
worker.postMessage) and the return of data to the main thread (
This graph reflects this cost. We can see that increased data sizes result in increased transfer times. More usefully we can deduce:
- Sending an object of 1000 keys or less via
postMessagecomes in sub-millisecond on Chrome
- Over this we have more noticeable transfer costs to the worker; 100,000 comes in at ~35ms and 1,000,000 at ~550ms again on Chrome
onmessagetimings are fairly comparable to this, coming in slightly higher
There has been an open issue on Jason Miller’s greenlet library for a while now which asks about the performance implications of using the library. As such I extended my research to also explore the library.
Overall Greenlet performance is slower than inlined Web Workers when you combine posting to and from the Worker thread. This comes to ~850ms vs ~1700ms (i.e. around double) in Chrome at the 1,000,0000 key level but is slightly less pronounced in Firefox (~1500ms vs ~2300ms). It’s difficult to deduce why this is the case and may have something to do with the ES6+ to ES5 transpilation process in Webpack or some other factor that I am unaware of (please feel free to let me know if you have an idea!). Overall, however, it’s a substantially easier abstraction for developers. The main takeaways for people interested in using greenlet in anger are:
- Objects with sub 10,000 entries greenlet appear to be sub 50ms for Chrome and Firefox
- Increase fairly linear after that point (~150ms for 100,000 vs ~1500ms for 1,000,000)
Data Tansfer Using JSON.stringify and JSON.parse
Using stringify and parse appears to yield fairly comparable results on Chrome but generally performs better than passing raw objects on Firefox. As such I would recommend having a look for yourself at the demo here to make your own conclusions or test with your own data.
On average Chrome outperforms Firefox especially under heavier transfers. Interestingly it performs substantially better at
postMessage back to the main thread from the worker by up to a factor of three, although I am unsure as to why. I would love to hear more about how this works on Safari, Edge and other browsers. Again here
JSON.parse might behave differently on these browsers.
Transferables behave more or less as expected with a near constant transfer cost; transfers of all sizes to and from the worker were sub 10ms.
The underlying idea here you can transfer values of type ArrayBuffer, MessagePort, ImageBitmap comparatively cheaply, which is a going to be a large performance boost if you’re using Web Workers, especially if your data is any considerable size. For example, you might transfer geometries as a
Float32Array for speedier transfer.
This is the point at which we look at ways of making decisions around when to use Web Workers. Ultimately this is not a simple question but we can make some inferences from the data collected:
Preventing Render Blocking
Objects of sub 50,0000 entries (or equivalent complexity) are on average in Chrome going to be less than 16ms to execute a
postMessage and shouldn’t have too much noticeable effect on render performance for the user (i.e. there is some possibility that a frame render or two is skipped). However, overall using a Web Worker in this situation will add up to ~100ms of overall processing time on top of the work the Worker actually has to do. The trade-off is not blocking the main thread with heavy work, but taking a little extra time for the results to come back.
Another recommendation could be to batch up heavy work into multiple
postMessages so that the chance of frame rendering being blocked at any point is substantially reduced. You could even spin up a pool of workers (see the fibrelite library I worked on for inspiration).
Transfering over 100,000 entry object (or equivalent) is most likely going to have a noticeable blockage on the main thread because of the cost of
postMessage. Again it may be worth breaking this down into a few smaller transfers to prevent render blocking.
The Trade Off
Ultimately the trade-off comes down to trading transfer time for preventing long render blocking tasks in the main thread. If you can avoid your workloads being render blocking you can transfer complex processing over to a Web Worker with the only cost of being the transfer times to the Web Worker, which as we’ve said for simple objects (1000 keys or less) should be sub-millisecond.
The bottom line is that Web Workers can be a big win in the right situations. Blocking the main thread with heavy work is never going to be great for user experience, so generally any time you think about doing heavy processing, especially when it’s not transfer intensive you should think of using a Web Worker. On top of this, it might be worth batching work to prevent extensive blocking transfer times.
This being said we can see that there is a cost to using Web Workers; the transfer times increases the overall time to work being finished. For some cases, this tradeoff might be undesired so it is worth considering.
I think there is some strong potential for Web Workers to become core elements of some web frameworks. We’ve seen this jump recently in the start of React Native DOM by Vincent Riemer which tries to move work off the main thread into worker threads leaving the main thread for rendering and handling user input.
Hopefully, the data presented in this article will make you able to come to a conclusion and strategy for using Web Workers in your applications. For smaller workloads that won’t clog up the main thread all so much, it might be worth contemplating using
requestIdleCallback with batched workloads.