High-performance Web Worker messages

Update: this blog post was based on the latest browsers as of early 2016. Things have changed, and in particular the benchmark shows that recent versions of Chrome do not exhibit the performance cliff for non-stringified postMessage() messages as described in this post.

In recent posts and talks, I’ve explored how Web Workers can vastly improve the responsiveness of a web application, by moving work off the UI thread and thereby reducing DOM-blocking. In this post, I’ll delve a bit more deeply into the performance characteristics of postMessage(), which is the primary interface for communicating with Web Workers.

Since Web Workers run in a separate thread (although not necessarily a separate process), and since JavaScript environments don’t share memory across threads, messages have to be explicitly sent between the main thread and the worker. As it turns out, the format you choose for this message can have a big impact on performance.

TLDR: always use JSON.stringify() and JSON.parse() to communicate with a Web Worker. Be sure to fully stringify the message.

I first came across this tip from IndexedDB spec author and Chrome developer Joshua Bell, who mentioned offhand:

We know that serialization/deserialization is slow. It’s actually faster to JSON.stringify() then postMessage() a string than to postMessage() an object.

This insight was further confirmed by Parashuram N., who demonstrated experimentally that stringify was a key factor in making a worker-based React implementation that improved upon vanilla React. He says:

By “stringifying” all messages between the worker and the main thread, React implemented on a Web Worker [is] faster than the normal React version. The perf benefit of the Web Worker approach starts to increase as the number of nodes increases.

Malte Ubl, tech lead of the AMP project, has also been experimenting with postMessage() in Web Workers. He had this to say:

On phones, [stringifying] is quickly relevant, but not with just 3 or so fields. Just measured the other day. It is bad.

This made me curious as to where, exactly, the tradeoffs lie with stringfying messages. So I decided to create a simple benchmark and run it on a variety of browsers. My tests confirmed that stringifying is indeed faster than sending raw objects, and that the message size has a dramatic impact on the speed of worker communication.

Furthermore, the only real benefit comes if you stringify the entire message. Even a small object that wraps the stringified message (e.g. {msg: JSON.stringify(message)}) performs worse than the fully-stringified case. (These results differ between Chrome, Firefox, and Safari, but keep reading for the full analysis.)

Test results

In this test, I ran 50,000 iterations of postMessage() (both to and from the worker) and used console.time() to measure the total time spent posting messages back and forth. I also varied the number of keys in the object between 0 and 30 (keys and values were both just Math.random()).

Clarification: the test does include the overhead of JSON.parse() and JSON.stringify(). The worker even re-stringifies the message when echoing it back.

First, here are the results in Chrome 48 (running on a 2013 MacBook Air with Yosemite):

Chrome 48 test results

And in Chrome 48 for Android (running on a Nexus 5 with Android 5.1):

Nexus 5 Chrome test results

What’s clear from these results is that full stringification beats both partial stringification and no-stringification across all message sizes. The difference is fairly stark on desktop Chrome for small messages sizes, but this difference start to narrow as message size increases. On the Nexus 5, there’s no such dramatic swing.

In Firefox 46 (also on the MacBook Air), stringification is still the winner, although by a smaller margin:

Firefox test results

In Safari 9, it gets more interesting. For Safari, at least, stringification is actually slower than posting raw messages:

Safari test results

Based on these results, you might be tempted to think it’s a good idea to UA-sniff for Safari, and avoid stringification in that browser. However, it’s worth considering that Safari is consistently faster than Chrome (with or without stringification), and that it’s also faster than Firefox, at least for small message sizes. Here are the stringified results for all three browsers:

Stringification results for all browsers

So the fact that Safari is already fast for small messages would reduce the attractiveness of any UA-sniffing hack. Also notice that Firefox, to its credit, maintains a fairly consistent response time regardless of message size, and starts to actually beat both Safari and Chrome at the higher levels.

Now, assuming we were to use the UA-sniffing approach, we could swap in the raw results for Safari (i.e. showing the fastest times for each browser), which gives us this:

Results with the best time for each browser

So it appears that avoiding stringification in Safari allows it to handily beat the other browsers, although it does start to converge with Firefox for larger message sizes.

On a whim, I also tested Transferables, i.e. using ArrayBuffers as the data format to transfer the stringified JSON. In theory, Transferables can offer some performance gains when sending large data, because the ArrayBuffer is instantly zapped from one thread to the other, without any cloning or copying. (After transfer, the ArrayBuffer is unavailable to the sender thread.)

As it turned out, though, this didn’t perform well in either Chrome or Firefox. So I didn’t explore it any further.

Chrome test results, with arraybuffer

Firefox results with arraybuffer

Transferables might be useful for sending binary data that’s already in that format (e.g. Blobs, Files, etc.), but for JSON data it seems like a poor fit. On the bright side, they do have wide browser support, including Chrome, Firefox, Safari, IE, and Edge.

Speaking of Edge, I would have run these tests in that browser, but unfortunately my virtual machine kept crashing due to the intensity of the tests, and I didn’t have an actual Windows device handy. Contributions welcome!

Correction: this post originally stated that Safari doesn’t support Transferables. It does.

Update: Boulos Dib has gracious run the numbers for Edge 13, and they look very similar to Safari (in that raw objects are faster than stringification):

Edge 13 results

Conclusion

Based on these tests, my recommendation would be to use stringification across the board, or to UA-sniff for Safari and avoid stringification in that browser (but only if you really need maximum performance!).

Another takeaway is that, in general, message sizes should be kept small. Firefox seems to be able to maintain a relatively speedy delivery regardless of the message size, but Safari and Chrome tend to slow down considerably as the message size increases. For very large messages, it may even make sense to save the data to IndexedDB from the worker, and then simply fetch the saved data from the main thread, but I haven’t verified this idea with a benchmark.

The full results for my tests are available in this spreadsheet. I encourage anybody who wants to reproduce these results to check out the test suite and offer a pull request or the results from their own browser.

And if you’d like a simple Web Worker library that makes use of stringification, check out promise-worker.

Update: Chris Thoburn has offered another Web Worker performance test that adds some additional ways of sending messages, like MessageChannels. Here are his own browser results.

16 responses to this post.

  1. This is the way science and engineering is done – empirical data comes first. For Transferable objects, I have measured the impact when sending images for processing from the main thread to web workers. It has resulted in speed ups, see https://glebbahmutov.com/blog/fast-legoization/

    Reply

  2. So it means the default serialization/deserialization is usually not as efficient as doing a JSON.stringify/parse? Sounds weird, unless stringifying the object has some limitations (maybe it’s not possible if there are circular references, while using the default serialization would work)?

    I never looked at web workers. As you said you can have “An optional array of Transferable objects to transfer ownership of. If the ownership of an object is transferred, it becomes unusable (neutered) in the context it was sent from and it becomes available only to the worker it was sent to.” https://developer.mozilla.org/en/docs/Web/API/Worker/postMessage It sounds like the idea is to not do any copy indeed, but potentially just play with pointers. You said it didn’t perform well, is it because it took time to convert your object to an ArrayBuffer?

    Reply

    • I can’t tell for circular references (I guess you’re right), but the default serialization has no problems sending a RegExp object to the web worker, whereas JSON.stringify() of course can’t deal with them (get’s converted to “{}”).

      Reply

  3. I have been using transferables with an add-on I wrote for Breeze.js to run inside a web worker. It takes 8 ms to stringify and convert an average sized request into an array buffer – the thread switch takes another 8-14 ms – compared to over 150ms to send an object via structured cloning (on Chrome)

    Great article, btw. I spent many, many weeks in the trenches begging, borrowing and stealing milliseconds from everyplace I could find them.

    Reply

  4. Thanks for the the thorough benchmarking. I’m writing a lib to wrap socket.io in a shared webworker and was about to do perf testing on the message passing but found your article.

    https://github.com/IguMail/socketio-shared-webworker

    I just added the serialization and will see how it performs. Most the data being sent is short messages..

    Reply

  5. Posted by David Roon on October 3, 2017 at 1:18 AM

    very interesting analysis! Thanks!
    Just a question. What is the unit of the message size? kb? mb?

    Reply

  6. Posted by evo on December 6, 2017 at 3:31 AM

    Nice one, can you also try one with SharedArrayBuffers that overcomes the problem with array buffer.

    Reply

  7. […] impossible. Then on the other hand, moving things to a worker creates its own costs. The cost of cloning data between threads can be expensive (note: to be fair, Chrome has improved their cloning performance since I wrote that post). There is […]

    Reply

  8. […] JSON.stringify 序列化需要传输的数据,根据 Nolan Lawson 的测试结果,证明传输 stringify […]

    Reply

  9. […] 年的 High-performance Web Worker messages 进行了测试, 确实如此. 但是文章的测试结果也只能停留在 2016 年. 2019 年 […]

    Reply

  10. […] 年的 High-performance Web Worker messages 进行了测试, 确实如此. 但是文章的测试结果也只能停留在 2016 年. 2019 年 […]

    Reply

  11. Posted by Ben Wiley on October 12, 2021 at 6:32 PM

    Would be a good idea to put that update at the top of the post in bold and larger font size. I was about to ask the question you answered but I missed the update the first time I read the post. Important for folks reading this to understand that your prior advice no longer applies with recent browsers. Indeed it makes sense that browsers were able to adapt, since it’s completely insane that serialization, which needs to happen anyway when passing messages between workers, would take longer unless done manually. Perhaps it’s thanks to your post that they noticed this bug and improved.

    Reply

  12. […] is hard. And we’re not all experts. I’ve made the same mistakes myself, in posts like “High performance web worker messages” (2016) – where I found the “one weird trick” that it’s faster to stringify an […]

    Reply

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.