Web Worker View and Diffing POC

I had an idea that spawned from interest that the community had towards Elm and WASM. I thought Web Worker could be a much more approachable solution, but still offer great benefits that could roll into WASM eventually. If you don’t know what Web Workers are, read Using Web Workers. Essentially, they give us access to move work that is usually done on the main JS thread to a separate worker thread. This gives us the benefit of freeing up the main thread to do other things while heavy tasks are being processed. In elm’s case, the app’s view and diff blocks felt very appropriate for this since it doesn’t need access to the DOM in order to process.

I tested this out by modifying the compiled output of one of my projects so I had 2 files. index.js is the main elm app and worker.js is the Web Worker. They are basically just copies of the compiled output so they have access to all necessary functions. In theory, we could reduce the amount of total code down to only what is needing by either.

I add the following to the global scope

var worker = new Worker("worker.js");

and modify the _Browser_element function

var _Browser_element = _Debugger_element || F4(function(impl, flagDecoder, debugMetadata, args)
	return _Platform_initialize(
		function(sendToApp, initialModel) {
			var view = impl.view;
			var domNode = args && args['node'] ? args['node'] : _Debug_crash(0);
			var sendCurrNode = true;

			worker.onmessage = function(e) {
				var currNode = e.data[0];
				var patches = e.data[1];
				domNode = _VirtualDom_applyPatches(domNode, currNode, patches, sendToApp);

			return _Browser_makeAnimator(initialModel, function(model)
				worker.postMessage([serializeFunctions(model), sendCurrNode ? serializeFunctions(_VirtualDom_virtualize(domNode)) : null]);
				sendCurrNode = false;

This sets up the worker to send the currNode only once and the new model. We receive the previous virtual dom currNode and the patches in order to update it.


var currNode;

onmessage = function(e) {
	var initialNode = e.data[1];
	if (initialNode) {
		currNode = initialNode;

	var model = e.data[0];

	var nextNode = A2(elm$core$Basics$composeR, author$project$Main$view, rtfeldman$elm_css$Html$Styled$toUnstyled)(model);
	var patches = _VirtualDom_diff(currNode, nextNode);
	postMessage([serializeFunctions(currNode), serializeFunctions(patches)]);
	currNode = nextNode;

This sets up our message to process our model and send the previous virtual dom currNode and the patches.


The view and diff took about 14ms, not a long time but can be longer for large apps or certain view logic. The main one I’m looking at is FP(First Paint) and L(Load). These are improved with Web Worker changes as you’ll see.


Had to do this since I can’t post 2 images as a new user


The view and diff still took about 12ms, but it was moved off to another thread. This has improved our pages initial load time, but does slightly impact our FCP(First Contentful Paint) time. This seems bad as we don’t normally want to delay the First Content, but in this case it keeps the page more responsive instead of being hung up doing work.


  • Web Worker is not a cure all, but could improve usability of many apps.
  • Web Workers do not share resources with the main thread which is why they communicate via messages. Browsers serialize the data and copy it to the worker and then deserailize it. This makes functions tricky, but manageable in Elm. Since Elm functions are pure, we can just manually serialize them ourselves and then deserialize them as you can see with the appropriately named functions I injected in the code.
  • There could be race conditions with messages being sent to a Worker out of order which could cause an old model to render on top of a newer one. There are solutions around this, but I didn’t run into issues with my test.

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.