Hello, fellow web perf enthusiast! Contribute to the 2024 edition happening this December. Click.

Web Performance Calendar

The speed geek's favorite time of year
2022 Edition
ABOUT THE AUTHOR

Stoyan (@stoyanstefanov) is an engineer on the WebPageTest.org team, formerly at Facebooks and Yahoo!, writer ("JavaScript Patterns", "React: Up and Running"), speaker (JSConf, Velocity, Perf.now()), toolmaker (Smush.it, YSlow 2.0) and a guitar hero wannabe.


So here’s the situation: our user initiates an action via a click. JavaScript kicks in. And it’s taking its time, doing an expensive operation. Meanwhile the browser’s frozen, the user is given no feedback of what’s going on, cannot interact with the page at all – no clicks, no test selection, no fiddling with menus. In fact, nothing’s happening, so the user clicks again. And since the action is a toggle, the end effect is the UI is back to the beginning as soon as the expensive operation is done.

Here’s a 7 seconds video demonstration:

What’s going on here?

After a quick commenting out of code (no devtools needed), the expensive operation is identified as being a diff of a large-ish (but real) chunk of HTML, using a Diff JS library and rendering the results to the DOM. The rendering is ok, the diffing not so much.

The relevant code parts are: including the 3rd party Diff library that gives us the Diff global:

<script src="/assets/js/vendor/diff-5.1.0.min.js"></script>

… and later on, when the user initiates the action, do the diff-ing and render the results available in the diff variable:

resultEl.innerHTML = 'Diffing...';
const diff = Diff.diffLines(before, after);
// render diff to the DOM

Note the “Diffing…” piece of feedback. The user never sees this, because the thread is blocked by the expensive diffLines() call.

The main thread

You may have heard about the main thread, a.k.a. the UI thread, and that JavaScript is single-threaded. So stuff takes time and the user is stuck, right? Wrong! Web workers are here to save the day!

A WebWorker can run in the background in its own context (e.g. has no idea what a window is) and communicate with the main script via messages. This messages are a bit of a pain, but not terrible, given the benefits provided. What’s annoying is that the web workers have to live in external files. I want my stuff where I can see it.

Luckily you can create URL objects with Blobs. This allows you to benefit from the WebWorkers while keeping the relevant parts of the code together.

Let’s do just that and fix the perf bug above.

Worker

The code for a regular worker is just like any script running in the global namespace. Here with an inline worker we can’t have rando globals. So let’s wrap all the worker code in a function. Here goes:

function diffWorkerImplementation() {
    importScripts(location.origin + '/assets/js/vendor/diff-5.1.0.min.js');
    onmessage = (e) => {
        postMessage(Diff.diffLines(e.data.before, e.data.after));
    };
}

The onmessage is how the main script talks to the worker, postMessage is how the worker talks back. The actual work (Diff.diffLines()) is the same, except the before/after are read from the event’s data.

Now the worker will not have access to the global window, so the global Diff is not available. The worker needs to import it within its context. This is done via importScripts(). In this case, the relative path '/assets/js/vendor/diff-5.1.0.min.js' doesn’t cut it, we need an absolute URL for the script. Which is a pain because of developing in various environments, but luckily location.origin is available, pointing to the hostname of where the worker lives. Success!

Note that since we’re importing the Diff library, you don’t need to include it as before. In other words, delete this part from the HTML:

<script src="/.../diff-5.1.0.min.js"></script>

Turning the function to a URL of a worker

As mentioned the workers’ code usually looks like global code. Meaning we can ditch the “function diffWorkerImplementation () {” and “}” and whatever’s left is valid worker code. Another way is to wrap the whole function with the self-invoking pattern, like

(function diffWorkerImplementation () { /*...*/ })()

Let’s do that. Here’s the scary-looking but perfectly valid way to create a URL from a Blob and pass it to the Worker constructor:

const diffWorker = new Worker(
    URL.createObjectURL(
        new Blob(
            [`(${diffWorkerImplementation.toString()})()`], 
            {type: 'text/javascript'}
        )
    )
);

With this spanking new worker, we’re ready to rumble.

Using the inline worker

At this point it doesn’t matter if this was an inline or external worker. Using it is the same: post a message (postMessage()) and when the answer comes back (onmessage), proceed to render the result as before:

// this time the user has a chance to see this piece of feedback
resultEl.innerHTML = 'Diffing...';

// ask the worker
diffWorker.postMessage({
    before,
    after,
});

// listen to a response from the worker
diffWorker.onmessage = (msg) => {
    const diff = msg.data;
    // render the `diff` result just like before
};

Results

And that’s all folks! Now we keep the page responsive while the heavy computation is happening and we even stayed in the same script. No reason to have the worker in a separate file and lose the context (in terms of developer experience). Here’s our “after” video:

As you can see the user can get feedback, and keep clicking and selecting things and poking around the page even though the heavy diffing is going on.