Performance tuning with Web Workers

I like performance tuning. In a strange way, it's sort of relaxing to open the dev tools profiler and dig around for long tasks and slowish network requests. This note is about one of those times, and how leveraging Web Workers resulted in a 30ms reduction in this site's Total Blocking Time. It's not much, but at scale, it can be.

Perceived performance

I think about performance primarily in terms of perceived performance. Metrics then help to guide the directionality of the process. Web performance is an inexact science due to the sheer number of variables at play:

Given these circumstances, all testing is at best an estimate. Lighthouse, an industry-standard webpage audit tool, underlines this with a disclaimer: "Values are estimated and may vary." With this in mind, let's see the code.

The case in question

All of the code that follows is open source and can be found on GitHub 😄

I have a website, and it has blog posts (you're reading it). There's an index page that displays a list of all these posts I've written. When you first land on that page, a number of things happen:

First, the router dynamically imports the JavaScript bundle for the page that will be displayed -- in this case, the notes page. Once the page JS is loaded, it then requests the page data that was prefetched at compile time by Basework, the experimental web framework I wrote that this site was built with. Finally we have prefetchNextPages, which I will describe in more detail after we see the whole snippet:

// Fetch bundle and render template
import(/* webpackChunkName: "[request]", webpackInclude: /\.js$/ */ `../pages/${page}`).then(() => {
  const pageElem = createPage(page);
  xhr(`${page}-data.json`).then(data => {
    setPageData(pageElem, data);
    if (typeof pageElem.setState === 'function') {
      pageElem.setState();
    }
    prefetchNextPages(pageElem, !isPrerendering);
  }).then(() => {
    dispatchEvent(new CustomEvent('basework-complete', { bubbles: true }));
  });
});

The final piece, prefetchNextPages does exactly what it sounds like it does -- it makes network requests in the background for all links that you as the user may navigate to next. My implementation is the most basic. I collect all the anchor tags for each article on the page, and that's what is prefetched. Let's have a look:

// TODO - Do this in a worker
function prefetchNextPageData(currentPage) {
  const anchors = Array.from(currentPage.querySelectorAll(`a[href^="/"]`));
  const paths = new Set(anchors.map(anchor => getUrlParts(anchor.href).pathname.replace(/\//, '')));
  const requests = Array.from(paths).map(path => xhr(`${path}-data.json`));
  Promise.all(requests)
    .catch(error => {
      console.warn(`Failed to prefetch page data`, error);
    });
}

The TODO here is exactly what we're driving towards. But first, here's the function declaration for getUrlParts, which as you might expect, takes a url and returns an object with its parts:

function getUrlParts(url) {
  let a = document.createElement('a');
  a.href = url;

  return {
    href: a.href,
    host: a.host,
    hostname: a.hostname,
    port: a.port,
    pathname: a.pathname,
    protocol: a.protocol,
    hash: a.hash,
    search: a.search
  };
}

Alright, so we've seen what I had in production. What are the implications of taking this approach? The main thread that our code runs on in the browser is responsible for everything, and adding this extra work to request things that aren't even on the screen at present can adversely affect the perceived performance of what is on screen at present. But we still want to prefetch the next pages so that when the user clicks on a link that leads to it, the browser has already cached the data required for that page. How to bridge this gap? Enter the Web Worker API.

Share the work

Surma has a fantastic talk with a fantastic title:

The main thread is overworked & underpaid

Agreed, let's give some of that work to a Web Worker! MDN has this to say:

Web Workers make it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down.

Alright, the first step we need to take in order to leverage a worker for our case is not start writing the code, but instead think about how it will work with our build/dev setup (unfortunately 😞). In my case the question is more specifically, how will it work with webpack?

My site is built with a framework I wrote that has pretty sensible defaults built in for webpack. To add some custom webpack configuration, I create a webpack-config.js file at my project root like so:

/**
 * Custom base webpack config.
 * Merged with default configs in Basework.
 */
const createCustomWebpackConfig = async () => {
  return {
    module: {
      rules: [
        {
          test: /-worker\.js$/,
          use: { loader: 'worker-loader' }
        }
      ]
    }
  }
}

module.exports = createCustomWebpackConfig;

This will get picked up by Basework and merged in with the default config. There are tons of articles about worker-loader so I won't spend much time on it, but in short it catches any workers you import and manipulates them at bundle time so they can be used. The reason for this is the Worker constructor's first parameter is a url to the file where your worker is defined, which as you likely are aware is always the tricky part with webpack -- the place where your worker file is saved in your source code is not where it will be after it's bundled by webpack. worker-loader is one solution that conforms with the "loader" convention used by webpack.

Now that we have the config and have npm i worker-loader --save-dev in our project, we can define our worker:

const { xhr } = require('../utils/xhr-util');

/**
 * Web worker that receives a set of page paths to prefetch data for.
 */
onmessage = function (event) {
  const paths = event.data || [];
  const requests = Array.from(paths).map(path => xhr(`${path}-data.json`));
  Promise.all(requests)
    .catch(error => {
      console.warn(`Failed to prefetch page data`, error);
    });
}

We could also use fetch instead of xhr, I just used xhr because I already have an xhr util defined from usage elsewhere. We define the onmessage function, which will be called after we instantiate our worker and call the postMessage method defined in the worker spec:

import Worker from '../workers/prefetch-worker';
import { getUrlParts } from './url-util';

// Other code removed for brevity

const prefetchNextPages = (pageElem, shouldPrefetch) => {
  if (shouldPrefetch) {
    try {
      const prefetchWorker = new Worker();
      const anchors = Array.from(pageElem.querySelectorAll(`a[href^="/"]`));
      const paths = new Set(anchors.map(anchor => getUrlParts(anchor.href).pathname.replace(/\//, '')));
      prefetchWorker.postMessage(paths);
    } catch (error) {
      console.warn('Failed to prefetch next page data', error);
    }
  }
}

We've imported, instantiated and called postMessage to transport data into our worker. Here's the catch -- we have to be very careful what we pass into the worker. It can't accept DOM elements, function objects, and a whole host of other things. In fact, MDN has a list of functions and classes available to Web Workers. So unlike before, we have to split some of the logic out of our prefetch code block so we can have something permissible to send to our worker. In the case above, I pass a Set object that contains a list of paths to request data for.

The gains

To be frank, my tiny site really doesn't need to go to such an extent to optimize the request of a dozen or so blog posts. The perceived performance gains are not discernible at this scale. But hear me out. I ran a Lighthouse audit on both versions of the site (the worker implementation in a separate branch deploy on Netlify), and there were two clear differences:

Zooming in on this TBT metric, as Google defines it:

Sum of all time periods between First Contentful Paint and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds.

30ms is not much. However, if we do some back-of-the-napkin arithmetic and imagine we have 1,000 blog posts:

At a scale of a thousand blog posts with the current architecture, using a web worker could save 2.5 seconds of load time for the user. Of course we should implement other solutions like pagination or infinite scroll loading in that scenario, but the point stands -- web workers improved performance on my site. Whether they are a viable approach for your site or problem set, as they say, it depends! Either way, I hope you found this useful in some way 😄


Thanks for reading! Go home for more notes.