Web Workers to the Rescue – How to Work with JSON Strings without Blocking User Interactions

Last updated Feb 8, 2024 Published May 10, 2023

The content here is under the Attribution 4.0 International (CC BY 4.0) license

Web workers have been around for a while now. But how you use them depends on the type of application you have. One way you might use web workers is to improve the performance of your apps.

As JavaScript is single threaded and has the event loop execution model, you don’t want to write blocking code. Doing so will lead to a poor experience for the end user as the application may freeze and not respond to any input.

In this tutorial, we’ll develop an application that deals with JSON strings and understand the challenges involved in dealing with the computation of large strings. To make the user experience better, we’ll use web workers to smooth things out, without blocking user interaction.

The JavaScript Event Loop

JavaScript is a popular programming language for web development, not only in the browser but also on the back-end.

JavaScript is single-threaded by nature, meaning that it runs on a single thread. But it can also be asynchronous using the event loop as a model of execution. You can learn more about event loops in this talk “In The Loop” from JSConf.Asia 2018.

Event loops allow programmers to run code without blocking its execution while waiting for a future value. This model is applied regardless of the environment in which JavaScript is being executed, whether it’s in the browser or the server.

In applications that were executed on the client side, the browser still lacked a way to execute heavy computation tasks in a way that was not blocking or didn’t disrupt the user experience.

For example, Demian Renzulli and Andrew Guan depicted this scenario through the game PROXX. They share how the authors of the game used web workers to compute the game logic and the perceived performance from the user interaction perspective. Surma goes into detail to explain how PROXX was built in a case-study fashion.

What are Web Workers?

Web workers were created in 2009 and according to caniuse.com, they’re widely supported by modern browsers. In their early days, the proposed web workers were developed by a working group that supported by w3c.

In the proposal, the authors divided web workers into two categories: web workers that run for a specific task (or as they’re called in the MDN Web Docs, dedicated workers), and shared workers that are available across pages and different pages can connect to them. In this tutorial, we’ll just focus on dedicated workers.

There are a couple similarities between web workers and service workers that we should mention:

  • They can run only from a server – trying to access them from the file system won’t work.
  • Accessing the Document Object Model (DOM) is not allowed.

Service workers are used to handle offline use cases and intercept requests. Web workers are meant to be a general-purpose computation alternative so that you don’t overload the main thread of the browser.

Demian Renzulli and Andrew Guan also dedicated a section of their work to explaining those differences.

As a simple analogy, web workers act as a thread would act in a programming language like Java. The main thread launches the web worker through the new operator and it will be created and wait for the task to be executed. From there, a relationship between the main script and the worker is created.

This does not mean that from here the browser will off-load all the heavy computation automatically. It is still the responsibility of the developer to decide which work should be delegated. Let’s see how it works with an example.

First, we define a script that will be the worker. Later on, we invoke the worker passing the script as the first argument in the constructor, such as:

const myWorker = new Worker('src/script.js');

Another way of launching the worker is through a raw string with source code that is then passed as a Blob. With this approach you don’t need to declare the worker in a separate file.

Besides that, it is also possible to use the tag script with type “javascript/worker”, but both approaches are not widely shared in tutorials across the internet. Green calls this approach an “inline worker”.

const code = 'any javascript code here';const myWorker = new Worker(URL.createObjectURL(new Blob([code])));

With the worker created, we can start to communicate with it. The communication with the web workers happens via events, as any other Application Programing Interface available in the Document Object Model would. To react to any event from the main thread, we listen to the message event. To communicate back to the parent who created the worker, we use postMessage.

The following example is taken from the web worker specification. It depicts a simple example of listening to the worker’s computation.

Note that in the code example that follows, we use onmessage. This is due to its API. Using message would require us to use addEventListener instead.

var n = 1;
search: while (true) {
  n += 1;
  for (var i = 2; i <= Math.sqrt(n); i += 1)
    if (n % i == 0)
     continue search;
  // found a prime!
  postMessage(n);
}

Then, in an index.html file:

<!DOCTYPE HTML>
<html lang="en">
 <head>
  <meta charset="utf-8"/>
  <title>Worker example: One-core computation</title>
 </head>
 <body>
  <p>The highest prime number discovered so far is: <output id="result"></output></p>
  <script>
   var worker = new Worker('worker.js');
   worker.onmessage = function (event) {
     document.getElementById('result').textContent = event.data;
   };
  </script>
 </body>
</html>

You can also terminate workers. This frees up resources that are allocated in the browser. Terminating a worker means killing its execution, and the communication between the main thread and the worker is removed. To establish it back, the worker needs to be created again.

There are two ways of terminating a worker: first, it can be called from the main thread. In this example, it is the script in the index.html. You can also terminate it from inside the worker (StackOverflow has a question that dives into this subject).

From the main thread, terminating a worker would be something like this:

<!DOCTYPE HTML>
<html lang="en">
 <head>
  <meta charset="utf-8" />
  <title>Worker example: One-core computation</title>
 </head>
 <body>
  <p>The highest prime number discovered so far is: <output id="result"></output></p>
  <script>
   var worker = new Worker('worker.js');
   worker.onmessage = function (event) {
     document.getElementById('result').textContent = event.data;
     worker.terminate();
   };
  </script>
 </body>
</html>

From the worker.js, you don’t need the worker.terminate, you can call close directly. The message to the parent will be posted and then the worker will be terminated immediately:

var n = 1;
search while (true) {
  n += 1;
  for (var i = 2; i <= Math.sqrt(n); i += 1)
    if (n % i == 0)
     continue search;
  // found a prime!
  postMessage(n);
  self.close();
}

You don’t need to terminate the worker after its execution, but in the absence of it, resources will be consumed. Figure 1 below depicts this scenario (to inspect what is going on with the worker, the tab “Sources” is used).

On the left, you can see the list of workers created. Those were created when the user clicked on the button “click me worker”.

Clicking on the button once creates one worker, and the button will create as many workers as times it’s clicked. For further inspection, you can look at the source code on GitHub.

Figure 1: Web worker with multiple instances running, if no close function is invoked, the worker instance keeps listening to events consuming resources. Figure 1: Web worker with multiple instances running, if no close function is invoked, the worker instance keeps listening to events consuming resources.

You can create as many workers as you want – the web workers specification does not force any hard limit. But the resources in the real device are limited as well, so take care not to create too many.

Figure 2: Once the worker finishes its job it can invoke the close function to terminate its execution. Figure 2: Once the worker finishes its job it can invoke the close function to terminate its execution.

Figure 2 shows that the worker closes itself once the computation is done. On the left you can see only one.

With this approach the workers will still be created (as many times as the user clicks) – but the difference is that each one will terminate itself after communicating to the main thread that the work is done.

Lastly, we need to discuss the execution model that changes when a worker is implemented.

With a worker, a new actor is in place, and the code that used to run synchronously with the script now delegates that to the worker and waits for its response.

There is a cost associated with creating and disposing of workers. The life-cycle is more complex if we compared that with the code execution without it.

In the next section, I’ll introduce you to the JSON tool so you can understand what it does and what problems it solves.

JSON Tool – the Application that Parses and Validates JSON

To follow along with this section, I assume you have basic knowledge of React.js.

JSON tool was created due to the lack of privacy that the formatting tools on the web have when it comes to dealing with sensitive data.

According to ThoughtWorks in the tech radar volume 27 developers should hold the practice of formatting or sharing information in formatting tools that are not complying with data jurisdiction.

The problem is that there is no easy way to guarantee that those tools provide a private experience, the way that JSON tool tackles it is through an open source code and no usage of any kind of tracking, logging or cookies that aims to collect user information.

JSON tool offers a few features that become handy when working with APIs that deal with JSON, some of the features included are:

  • JSON content validation, it shows an error message warning invalid JSON.
  • Buttons to allow easy interaction with the clipboard (paste and copy to the clipboard).
  • Search through the JSON string (offered by the code mirror editor).
  • Upload a JSON file.

Besides, there is an interaction with the clipboard allowing developers to copy/paste strings easily. Such features allow developers to inspect JSON values and carry on their daily activities.

Figure 3: JSON tool opened with JSON string loaded, in the left the original content, on the right, the formatted output. Figure 3: JSON tool opened with JSON string loaded, in the left the original content, on the right, the formatted output.

The tool uses reactjs, tailwind, codemirror as an editor in the browser, testing-library for testing, and format-to-json to deal with JSON and for testing, it uses Jest, the standard for reactjs applications and it was bootstrapped using Create React App.

It has a packaged version through snapcraft but it is also available in its raw format through github pages. Building a JSON tool that does not track any information about who is using it and respecting data privacy is a task that any programmer can carry out without much effort.

Googling “JSON prettier” gives many different websites. Therefore, dealing with edge cases is where the challenges are.

The structure of the JSON tool is grouped in the way that reactjs documentation suggests, there are components to represent different responsibilities of the application: pages, user interface components and the core (some of the files such as App.tsx and the types folder have been removed to fit in here). The source code is available on Github.

src                                            
├── App.tsx                                    
├── components                                 
│   ├── tailwind.d.ts                          
│   └── ui                                     
│       ├── editor                             
│       │   ├── default-options.ts             
│       │   ├── EditorContainer.tsx            
│       │   └── JsonEditor.tsx                 
│       ├── Footer.tsx                         
│       ├── Header.tsx                         
│       ├── io                                 
│       │   ├── Button.tsx                     
│       │   └── InputText.tsx                  
│       ├── Label.tsx                          
│       ├── layout                             
│       │   └── Default.tsx                    
│       ├── Loading.tsx                        
│       └── menu                               
│           ├── JsonMenu.tsx                   
│           └── ResultMenu.tsx                 
├── core                                       
│   ├── cleanUp.ts                             
│   ├── formatter.ts                           
│   └── worker.js                              
├── pages                                      
│   ├── Editors.tsx                            
│   └── Settings.tsx   

The adopted structure wasn’t used for a particular reason, it was mainly to make it reflect a structure that makes sense on how the components are displayed on the screen.

In short, each of those folders/files has the following responsibilities:

  • pages: is the place where the pages of the application are stored, the main one is the Editors.tsx this is the page that is rendered by default when the application is launched.
  • components: is the place where all the user interface components are stored, those are stateless components and can be used across the application.
  • Last but not least is the core. The folder where the formatting JSON lies, in here file formatter.ts is a wrapper for the library format-to-json that is used in the application.

In the next section, we will dive into the limitations that it reached when dealing with large JSON strings.

Exploring the limitation of JSON computation without web workers

The tool depending on the hardware in which the tool is running formats JSON strings between 100kb and 700kb without blocking the user interface.

Therefore, using a JSON that is bigger than that causes the user interface to freeze and not respond to user interactions, based on experiments of generating JSON, a JSON file that is bigger than 1MB is enough to cause this effect.

As the code was written in react, the code used to handle the features described in the previous section was in a single place that is shown in the following code snippet:

const onJsonChange = useCallback(async (value: string) => {
  setError('');

  if (!spacing) return;

  try {
    if (value) {
      JSON.parse(value);
    }
  } catch (e: any) {
    setError('invalid json');
  }

  let format = new Formatter(value, 2);

  const parseSpacing = parseInt(spacing);
  if (!isNaN(parseSpacing)) {
    format = new Formatter(value, parseSpacing);
  }

  const result = await format.format();

  setOriginalResult(value);
  setResult(result);
}, [spacing]);

The function onJsonChange was invoked every time a change happened in the application, whenever the user changed the desired spacing or changed the JSON text. This approach allowed for keeping the code of handling JSON in a single place, any change required to the JSON text is centralized here.

Figure 4: Sequence diagram that depicts a synchronous code execution for validating and formatting JSON.

Figure 4: Sequence diagram that depicts a synchronous code execution for validating and formatting JSON.

It was also the reason for freezing the main thread leading to the blocking user interface. The mitigation concluded that the issue was the formatting string was done by benchmarking the code execution with console.time and console.timeEnd.

const onJsonChange = useCallback(async (value: string) => {
  /** skipped code **/
  try {
    console.time();
    if (value) {
      JSON.parse(value);
    }

    console.timeEnd();
  } catch (e: any) {
    setError('invalid json');
  }

  /** skipped code **/
}, [spacing]);

This approach lead to the decision of moving the code that was parsing the JSON and validate it to the worker. One might argue that the reactjs code could have been inspected to mitigate the slowness.

No inspection was done on the internals of reactjs as the measurement of the time showed that the computation done in a single function was the root cause.

Note that the first step just loads the JSON into the codemirror is done pretty fast and on-demand, as the syntax highlight occurs as the view of the JSON view is scrolled.

Implementing JSON computation with web workers

One of the benefits of web workers is the possibility it has to delegate a heavy intense computation problem off the main thread, buying the tradeoff that it introduces a more complex code execution flow. Indeed, the book “Web Workers: Multithreaded Programs in JavaScript” by Ido Green listed “Encoding/decoding a large string” as one of the main reasons to use web workers, not only that he also states that “If your web application needs to complete a task that takes more than 150 milliseconds, you should consider using a Web Worker”.

In other words, using web workers in the JSON context brings benefits for users of the tool, allowing them to keep using the tool while it is working under the hood, the following video is the result of implementing the web worker.

The challenge to implement the worker in the application was in the test side of things that will be tackled in the next section, in there we will go over the steps to implement a worker that parses, validate and format a JSON string.

The first step in moving the code was to create a mechanism of “request” and “response” with the worker, in other words, posting the received JSON and then receiving it if it was valid or not, this piece of history can be seen on Github.

Figure 5: Sequence diagram that depicts the worker execution model for validating and formatting JSON. Figure 5: Sequence diagram that depicts the worker execution model for validating and formatting JSON.

Therefore, at this point, the same result was reached, different implementation but the same freezing effect in the user interface, the bit missing was the formatting JSON library which was done via the component still.

Once this was done, the user interface stopped lagging, the following code is the result of the web worker implementation, the worker validates the JSON text and formats it based on the user spacing settings.

importScripts('https://unpkg.com/[email protected]/fmt2json.min.js');

if('function' === typeof importScripts) {
  addEventListener('message', async (event) => {
    if (!event) {
      return;
    }

    const value = event.data.jsonAsString;
    const spacing = event.data.spacing;

    if (value) {
      // eslint-disable-next-line no-undef
      const format = await fmt2json(value, {
        expand: true,
        escape: false,
        indent: parseInt(spacing)
      });

      try {
        JSON.parse(value);
      } catch (e) {
        console.error('error from worker: ', e);
        postMessage({ error: true, originalJson: value, result: format.result });
        return;
      }

      postMessage({ error: false, originalJson: value, result: format.result });
      return;
    }
    // empty json was given
    postMessage({ error: false, originalJson: value, result: value });
  });
}

The source code of the components was transformed to use the events as per web worker specification, the addition here is in the amount of the component. Now the component binds the worker life cycle to it when mounted:

useEffect(() => {
  worker.current = new Worker(URL.createObjectURL(new Blob([code])));
  worker.current.onmessage = (workerSelf: MessageEvent) => {
    setError('');
    if (workerSelf.data.error) {
      setError('invalid json');
    }

    setResult(workerSelf.data.result);
    setInProgress(false);
  };
}, []);

At this stage, it waits for any new information from the worker, and based on the income messaging it updates its state. The last piece of the puzzle is to post the JSON string to the worker, which is depicted as follows:

const onChange = (eventValue: string, eventSpacing: string) => {
  if (worker.current) {
    worker.current.postMessage({ jsonAsString: eventValue, spacing: eventSpacing });
  }
  setOriginalResult(eventValue);
  setInProgress(true);
};

Here, the previous code that was doing all the work and freezing the user interface, now just posts the received JSON string to the worker.

Testing an application that uses web workers

Therefore, there is a specific point in using web workers that have not been covered so far: the testing aspect of it.

Testing as any activity that practitioners do on the daily basis is especially difficult when dealing with APIs in the browser, those are responsible for making the setup of the test more complex, as most of them need to be replaced by test doubles.

Besides, testing web workers does not receive attention in the resources available in the web, the focus is usually on the learning aspect of web workers, and not how to deal with this in the test environment.

In that sense, testing web workers brings new challenges as the “Worker” object is defined under the window in the browser, using Jest such an object does not exist as depicted by Figure 6.

Figure 6: Undefined Worker in the js-dom environment. Figure 6: Undefined Worker in the js-dom environment.

JSON tool has the test suite developed using react testing library as it is not coupled to any specific component leading to a complex test-double strategy, instead, the worker was a refactoring step, the end goal was to keep the same behavior, but now, with the web worker.

To give a concrete example of a test that wasn’t coupled with the actual implementation of the production code, take a JSON file and format it according to the user’s preference.

it('should format json from uploaded file', async () => {
  const file = new File(['{"a":"b"}'], 'hello.json', { type: 'application/json' });

  const { getByTestId } = render(<App />);

  await act(async () => {
    await userEvent.upload(getByTestId('upload-json'), file);
  });

  await waitFor(() => {
    expect(getByTestId('raw-result')).toHaveValue(`{
  "a": "b"
}`);
  })
});

Thanks to Jason Miller the library jsdom-worker offers an implementation of the worker API under js-dom, no threads, but it mocks the behavior that the API would have in the browser.

To get it up and running there are two steps:

  1. install the library with the command: npm i jsdom-worker
  2. Update the setupTests.ts with the line: import ’jsdom-worker’ - As Jest is used as the testing framework, the setupTests.ts or setupTests.js is the place to add the import. Therefore, other testing frameworks that use js-dom might need to find the corresponding setup in their frameworks. setupTest is the place that loads the required dependencies that the test suite needs to run, this is a centralized place to not need to set up the jsdom-worker in every test file.

Once the proper setup was done, the errors were gone and the test suite was being executed as it was before the change.

At the time of writing, jsdom-worker does not support shared workers, in the GitHub page there is an issue open to handle this situation.

Issues faced

The jsdom-worker is a drop-in replacement for any issue faced by the application, therefore, while developing the application, there was a single issue that prevented the script to work properly and thus using a separate file instead of the Blob as depicted by Figure 7.

Figure 7: jsdom-worker requesting worker.js to localhost:80 and receiving an error. Figure 7: jsdom-worker requesting worker.js to localhost:80 and receiving an error.

The library uses fetch to fetch the script that should be loaded by the Worker, therefore, the host used is always localhost:80, in this case, the library running on development mode is exposed in port 3000, making the test fail.

Another thing to note is that the examples given by the library are using the Blob pattern, which could be an indication that Blob is favored instead of using the script file.

Wrapping up and where go next

To prevent user interface freeze, Web workers allow applications to offload heavy computations to a dedicated worker improving the user experience. Nevertheless, its implementation adds complexity by trading off the synchronous code execution with an event-oriented one.

The testing side is also affected as it requires a library to mimic the behavior that the web worker would have in the browser, therefore, the library has two main limitations:

  • it does not support scripts as the browser would normally do
  • and it does not support shared workers.

The real benefit is on the user side of the equation, despite not increasing any performance regarding the JSON computation, it prevents the browser to freeze interrupting users during their tasks. That is what matters the most, as the use case from PROXX also showed those benefits.