Introduction to workers and why we should use them
Tags: worker, performance, browser
Published: 2021-11-12 03:21:21

As we know that browser runs all the Javascript of a web page in a single thread - the main thread, any excessive Javascript code may block the main thread and makes the page look laggy or even unresponsive.

We may improve this by off-loading some tasks to workers, and turns the single-threaded app into a multi-threaded, higher-performance, and potentially safer app.

## Introduction of workers

There are 3 kinds of workers in the browser context:

1. **Web worker** - including **dedicated worker**, and **shared worker**
2. **Service worker**
3. [Worklet](https://developer.mozilla.org/en-US/docs/Web/API/Worklet)- including [PaintWorklet](https://developer.mozilla.org/en-US/docs/Web/API/PaintWorklet), [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet), AnimationWorklet, LayoutWorklet.

I will focus on the web worker and service worker in this post, as worklet is still experimental and designed for more specific usages.

### Web worker

Web worker is designed as **the background thread for running scripts**. Web pages are **allow to spawn multiple workers** to do different tasks in an **isolated context**.

![](https://web-dev.imgix.net/image/tcFciHGuF3MxnTr1y5ue01OGLBn2/ZCC24V8uqi6HfFjRzuPq.png?auto=format&w=1000)

(Image from [Workers Overview](https://web.dev/workers-overview/))

We may create such thread dedicated to a web page via the [Worker](https://developer.mozilla.org/en-US/docs/Web/API/Worker/Worker) API:

```javascript
const myWorker = new Worker('worker.js');
```

Or sharing the thread among the same origin via the [SharedWorker](https://developer.mozilla.org/en-US/docs/Web/API/SharedWorker) API.

```javascript	
const mySharedWorker = new SharedWorker('sharedWorker.js');
```

Their usage is almost the same but **the connection between the web page and the shared worker is assigned ports**. To communicate with the shared worker, the web page need to go through the port assigned.

In the main thread:

```javascript
mySharedWorker.port.postMessage('Test message to worker.');
mySharedWorker.port.onmessage = (e) => {
  console.log('Receive data from shared worker:', e.data);
};
```

In the shared worker:

```javascript
onconnect = (e) => {
  const port = e.ports[0];
  port.onmessage = (e) => {
    port.postMessage('Hi, I received your message "${e.data}"!`);
  };
}
```

While communicating with the dedicated worker is more straightforward.

In the main thread:

```Javascript
myWorker.postMessage('Test message to worker.');
myWorker.onMessage = (e) => {
  console.log('Receive data from dedicated worker:', e.data);
};
```

In the dedicated worker:

```javascript
onmessage = (e) => {
  postMessage('Hi, I received your message "${e.data}"!`);
};
```

Another difference you may notice is that the dedicated worker is coupled with the web page, hence **it will be terminated once user close the page**.

### Service worker

Service worker is designed as an **extra layer working between the web page and the server**.

![](https://web-dev.imgix.net/image/tcFciHGuF3MxnTr1y5ue01OGLBn2/GmGcVnb2y1yNc4ZIFFQ8.png?auto=format&w=1248)

(Image from [Workers Overview](https://web.dev/workers-overview/))

Unlike the web worker, it has a more complicated lifecycle:

1. Register in main thread via `navigator.serviceWorker.register()`, and download the script for the service worker;
2. Install if the service worker is newly registered, or the script is updated;
3. Activate when no "old" service worker is controlling the clients under its scope;
4. Redundant when it is out-dated and being replaced.

It is given [a set of APIs and events](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API#interfaces) to achieve things like:

- caching data
- intercepting requests
- managing browser push notifications

On top of these capabilities, we do a lot more interesting things. We will discuss these later.

### Their limitations

Both web worker and service worker are quite powerful, but meanwhile they have various limitations. Including:

**No access to DOM, window object and some other APIs like localStorage**. This restriction keeps main thread and worker isolated and not disturbing each other. However, this also means if you would like off-load some heavy jobs like DOM operations, it's not possible with worker.

**Messages between the main thread and the worker are cloned** via [the structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) which does not work with various data types. And unlike `JSON.stringify()` simply strip off the data it does not support, passing a data type the structured clone does not support will simply throw an exception.

**Experimental and [limited support](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules) for ES modules.** As mordern browsers support ES module better, we could lower down quite some overhead on compiling our scripts, and run ES module directly in the browser. But, that is not quite the case in the workers. 

>  As of Mar 1, 2019, only [Chrome 80+](https://web.dev/module-workers/) supports this feature, while [Firefox has an open feature request](https://bugzilla.mozilla.org/show_bug.cgi?id=1247687). No other browsers are known to have support for production usage of worker scripts written as modules.

In these browsers, you may spawn a worker with ES module via:

```javascript
new Worker('worker.js', {
  type: 'module'
});
```

However, the ES module syntax `import` and `export` may not work well as you expected.

**Spawning a worker from a worker almost cannot work**, although some browser claims that we can spawn a sub worker from a dedicated worker. This could be a bit frastrating sometimes as some library (e.g. esbuild-wasm) has built-in worker logics, which prevent us from organizing the tasks properly according to our needs, and have to communicate between the worker in the library and our own worker through the main thread message channel.

##Performance improvement with workers

### Off-loading heavy tasks

As introduced previously in "Introduction of workers", we may assign some heavy tasks to workers to avoid blocking the main thread and affecting user experiences.

For example, when we have to group or process the raw data from BE for display, we may spawn a web worker to do this while continuing to render other things.

In the main thread, we spawn a web worker, then instruct the task and wait for the result:

```javascript
function getData() {
  const myWorker = new Worker('processData.js');
  return new Promise((resolve, reject) => {
    myWorker.onMessage = ({ data }) => {
      resolve(data);
      myWorker.terminate();
    }
    myWorker.onError = (e) => {
      reject(e);
      myWorker.terminate();
    }
    myWorker.postMessage({ sortBy: 'update_time' });
  });
}

getData().then(data => console.log(data));
```

In the `processData.js`:

```javascript
function process(rawData, options) {
  const processedData = rawData;
  // Do the processing
  return processedData;
}

onmessage = ({ data: options }) => {
  fetch('/get_raw_data')
  .then(res => res.json())
  .then(rawData => process(rawData, options))
  .then(data => postMessage(data));
};
```

Another classic use case is to zip/unzip files in the browser. The workers are often integrated within the library, like [unzipit](https://www.npmjs.com/package/unzipit), [js-untar](https://www.npmjs.com/package/js-untar), etc.

### Prefetching data

Service worker provides APIs for caching data, and intercepting requests in the browser. This provide us an opportunity to get some data ready in advance.

In the main thread, we simply register the service worker script:

```javascript
if ("serviceWorker" in navigator) {
  // do make sure the service worker script is under the right folder as it will only control the requests from the scripts under the same folder
  navigator.serviceWorker.register("/sw.js");
}
```

And fetch data as normal:
```javascript
fetch('/get_data').then(res => {
  // If the service worker is successfully activated and the prefetched data is ready, you shall get the response immediately
  // ...
});
```

In the `sw.js`, we load data ahead and put into cache:

```javascript
const preFetchUrls = [
  '/get_data'
];
caches.open('prefetchedData').then(prefetchedData => {
  preFetchUrls.forEach(requestUrl => {
    fetch(requestUrl)
      .then(res => prefetchedData.put(requestUrl,res));
  });
});
```
And intercept the request to replace the response with the cache if it exists:

```javascript
addEventlistener('fetch', e => {
  const { method, url } = e.request;
  if (method !== 'GET' || !preFetchUrls.includes(url)) return;
  e.respondWith(async () => {
		const prefetchedData = await caches.open('prefetchedData');
    const cachedResponse = await cache.match(event.request);
    return cachedResponse || fetch(event.request);
  });
});
```

### Caching for loading faster next time with Workbox

In the use case above, we made use of the caching and request intercepting ability of service worker to load some data ahead. We may also use these abilities to help our return user to load page faster by caching the necessary files.

Here I would like to recommend using [Workbox](https://developers.google.com/web/tools/workbox), which will largely simplify the setups and apply different strategies easily.

In the main thread, we will need to register the service worker script as before:

```javascript
if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");
}
```

While in the `sw.js`, we will import the Workbox libraries this time, which means you will require to bundle this file before use.

The example code from Workbox website is sufficient for most of the use cases:

```javascript
import { registerRoute } from 'workbox-routing';
import {
  NetworkFirst,
  StaleWhileRevalidate,
  CacheFirst,
} from 'workbox-strategies';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
import { ExpirationPlugin } from 'workbox-expiration';

// Cache page navigations (html) with a Network First strategy
registerRoute(
  ({ request }) => request.mode === 'navigate',
  new NetworkFirst({
    cacheName: 'pages',
    plugins: [
      // Ensure that only requests that result in a 200 status are cached
      new CacheableResponsePlugin({
        statuses: [200],
      }),
    ],
  }),
);

// Cache CSS, JS, and Web Worker requests with a Stale While Revalidate strategy
registerRoute(
  ({ request }) =>
    request.destination === 'style' ||
    request.destination === 'script' ||
    request.destination === 'worker',
  new StaleWhileRevalidate({
    cacheName: 'assets',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [200],
      }),
    ],
  }),
);

// Cache images with a Cache First strategy
registerRoute(
  ({ request }) => request.destination === 'image',
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new CacheableResponsePlugin({
        statuses: [200],
      }),
      // Don't cache more than 50 items, and expire them after 30 days
      new ExpirationPlugin({
        maxEntries: 50,
        maxAgeSeconds: 60 * 60 * 24 * 30, // 30 Days
      }),
    ],
  }),
);
```

If these are the only logics you needed in the service worker, you may also simply use a bundler plugin instead of copy paste and configure the bundler. 

For Webpack users, check this doc: [Workbox Webpack plugin](https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin).

For Rollup users, you may try the plugin [rollup-plugin-workbox](https://www.npmjs.com/package/rollup-plugin-workbox).

## Other usages with workers

### Mocking API without Node.js server

Once we have the ability to intercept a request, we may achieve some interesting things like mocking API. [MSW](https://mswjs.io/) is an perfect example.

Basically we could:

1. listen to the "fetch" event;
2. check the request matches our rules;
3. if it matches, replace with a cached response.

Just like what I demonstrated in the "Prefetching data" use case.

### As a data processing layer

Another interesting use case is separating a data processing layer from our UI layer. 

For example, when we are tracking the usage of an app, the app may throw all the information of a component to the worker. Then the worker selects the required data, and transforms the data into the right format before finally sending them to the server.

The layer isolation keeps the relatively non-critical but probably heavy tracking logics away from affecting the user experience, and crashing the app when there's something wrong.

### Syncing in the background

Have you encounter any super slow data operations such as uploading multiple large files, or simply the BE server respond slowly? In these cases, the user is likely have to wait for the server response before exiting it.

Service worker does not have this limitation! It keeps running as long as the browser is still active. Therefore, we could cache these data somewhere in the browser first (like indexDB), and let the service worker to communicate with server instead to allow the user doing other things while waiting.

### And more

There are a lot more possibilities with workers besides to the stuff mentioned above, like the well-known Progressive Web App, push notifications, code sandbox (without DOM access), etc.  

Let's be creative and bring the web app to the next level!