Introduction to workers and why we should use them

jennieji

Jennie

Posted on November 12, 2021

Introduction to workers and why we should use them

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- including PaintWorklet, 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.

(Image from Workers Overview)

We may create such thread dedicated to a web page via the Worker API:

const myWorker = new Worker('worker.js');
Enter fullscreen mode Exit fullscreen mode

Or sharing the thread among the same origin via the SharedWorker API.

const mySharedWorker = new SharedWorker('sharedWorker.js');
Enter fullscreen mode Exit fullscreen mode

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:

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

In the shared worker:

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

While communicating with the dedicated worker is more straightforward.

In the main thread:

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

In the dedicated worker:

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

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.

(Image from 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 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 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 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+ supports this feature, while Firefox has an open feature request. 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:

new Worker('worker.js', {
  type: 'module'
});
Enter fullscreen mode Exit fullscreen mode

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:

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));
Enter fullscreen mode Exit fullscreen mode

In the processData.js:

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));
};
Enter fullscreen mode Exit fullscreen mode

Another classic use case is to zip/unzip files in the browser. The workers are often integrated within the library, like unzipit, 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:

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");
}
Enter fullscreen mode Exit fullscreen mode

And fetch data as normal:

fetch('/get_data').then(res => {
  // If the service worker is successfully activated and the prefetched data is ready, you shall get the response immediately
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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

const preFetchUrls = [
  '/get_data'
];
caches.open('prefetchedData').then(prefetchedData => {
  preFetchUrls.forEach(requestUrl => {
    fetch(requestUrl)
      .then(res => prefetchedData.put(requestUrl,res));
  });
});
Enter fullscreen mode Exit fullscreen mode

And intercept the request to replace the response with the cache if it exists:

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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, 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:

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/sw.js");
}
Enter fullscreen mode Exit fullscreen mode

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:

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
      }),
    ],
  }),
);
Enter fullscreen mode Exit fullscreen mode

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.

For Rollup users, you may try the plugin 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 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!

💖 💪 🙅 🚩
jennieji
Jennie

Posted on November 12, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related