The Programmer News Hubb
Advertisement Banner
  • Home
  • Technical Insights
  • Tricks & Tutorial
  • Contact
No Result
View All Result
  • Home
  • Technical Insights
  • Tricks & Tutorial
  • Contact
No Result
View All Result
Gourmet News Hubb
No Result
View All Result
Home Tricks & Tutorial

Develop Faster JS Apps: the Ultimate Guide to Web Workers

admin by admin
December 1, 2022
in Tricks & Tutorial


In this tutorial, we’ll introduce web workers and demonstrate how you can use them to address execution speed issues.

Contents:

  1. JavaScript Non-blocking I/O Event-loop
  2. Long-running JavaScript Functions
  3. Web Workers
  4. Browser Worker Demonstration
  5. Server-side Web Worker Demonstration
  6. Alternatives to Node.js Workers
  7. Conclusion

JavaScript programs in browsers and on the server run on a single processing thread. This means that the program can do one thing at a time. In simplistic terms, your new PC may have a 32-core CPU, but 31 of those are sitting idle when your JavaScript application runs.

JavaScript’s single thread avoids complex concurrency situations. What would happen if two threads attempted to make incompatible changes at the same time? For example, a browser could be updating the DOM while another thread redirects to a new URL and wipes that document from memory. Node.js, Deno, and Bun inherit the same single-thread engine from browsers.

This isn’t a JavaScript-specific restriction. Most languages are single-threaded, but web options such as PHP and Python typically run on a web server which launches separate instances of the interpreter on a new thread for every user request. This is resource-intensive, so Node.js apps usually define their own web server, which runs on a single thread and asynchronously handles every incoming request.

The Node.js approach can be more efficient at handling higher traffic loads, but long-running JavaScript functions will negate efficiency gains.

Before we demonstrate how you can address execution speed issues with web workers, we’ll first examine how JavaScript runs and why long-running functions are problematic.

JavaScript Non-blocking I/O Event-loop

You might think that doing one thing at once would cause performance bottlenecks, but JavaScript is asynchronous, and this averts the majority of single-thread processing problems, because:

  • There’s no need to wait for a user to click a button on a web page.

    The browser raises an event which calls a JavaScript function when the click occurs.

  • There’s no need to wait for a response to an Ajax request.

    The browser raises an event which calls a JavaScript function when the server returns data.

  • There’s no need for a Node.js application to wait for the result of a database query.

    The runtime calls a JavaScript function when data is available.

JavaScript engines run an event loop. Once the last statement of code has finished executing, the runtime loops back and checks for outstanding timers, pending callbacks, and data connections before executing callbacks as necessary.

Other OS processing threads are responsible for calls to input/output systems such as HTTP requests, file handlers, and database connections. They don’t block the event loop. It can continue and execute the next JavaScript function waiting on the queue.

In essence, JavaScript engines have a single responsibility to run JavaScript code. The operating system handles all other I/O operations which may result in the engine, calling a JavaScript function when something occurs.

Long-running JavaScript Functions

JavaScript functions are often triggered by an event. They’ll do some processing, output some data and, most of the time, will complete within milliseconds so the event loop can continue.

Unfortunately, some long-running functions can block the event loop. Let’s imagine you were developing your own image processing function (such as sharpening, blurring, grayscaling, and so on). Asynchronous code can read (or write) millions of bytes of pixel data from (or to) a file — and this will have little impact on the JavaScript engine. However, the JavaScript code which processes the image could take several seconds to calculate every pixel. The function blocks the event loop — and no other JavaScript code can run until it completes.

  • In a browser, the user wouldn’t be able to interact with the page. They’d be unable to click, scroll, or type, and may see an “unresponsive script” error with an option to stop processing.

  • The situation for a Node.js server application is worse. It can’t respond to other requests as the function executes. If it took ten seconds to complete, every user accessing at that point would have to wait up to ten seconds — even when they’re not processing an image.

You could solve the problem by splitting the calculation into smaller sub-tasks. The following code processes no more than 1,000 pixels (from an array) using a passed imageFn function. It then calls itself with a setTimeout delay of 1 millisecond. The event loop blocks for a shorter period so the JavaScript engine can handle other incoming events between iterations:



function processImage( callback, imageFn = i => {}, imageIn = [] ) {

  const chunkSize = 1000; 

  
  let
    imageOut = [],
    pointer = 0;

  processChunk();

  
  function processChunk() {

    const pointerEnd = pointer + chunkSize;

    
    imageOut = imageOut.concat(
      imageFn( imageIn.slice( pointer, pointerEnd ) )
    );

    if (pointerEnd < imageIn.length) {

      
      pointer = pointerEnd;
      setTimeout(processChunk, 1);

    }
    else if (callback) {

      
      callback( null, imageOut );

    }

  }

}

This can prevent unresponsive scripts, but it’s not always practical. The single execution thread still does all the work, even though the CPU may have capacity to do far more. To solve this problem, we can use web workers.

Web Workers

Web workers allow a script to run as a background thread. A worker runs with its own engine instance and event loop separate from the main execution thread. It executes in parallel without blocking the main event loop and other tasks.

To use a worker script:

  1. The main thread posts a message with all necessary data.
  2. An event handler in the worker executes and starts the computations.
  3. On completion, the worker posts a message back to the main thread with returned data.
  4. An event handler in the main thread executes, parses the incoming data, and takes necessary actions.

worker processing

The main thread — or any worker — can spawn any number of workers. Multiple threads could process separate chunks of data in parallel to determine a result faster than a single background thread. That said, each new thread has a start-up overhead, so determining the best balance can require some experimentation.

All browsers, Node.js 10+, Deno, and Bun support workers with a similar syntax, although the server runtimes can offer more advanced options.

Browser Worker Demonstration

The following demonstration shows a digital clock with milliseconds that update up to 60 times per second. At the same time, you can launch a dice emulator which throws any number of dice any number times. By default, it throws ten six-sided dice ten million times and records the frequency of totals.

View the above demo on CodeSandbox:

Click start throwing and watch the clock; it will pause while the calculation runs. Slower devices and browsers may throw an “unresponsive script” error.

Now check the use web worker checkbox and start throwing again. The clock continues to run during the calculation. The process can take a little longer, because the web worker must launch, receive data, run the calculation, and return results. This will be less evident as calculation complexity or iterations increase. At some point, the worker should be faster than the main thread.

Dedicated vs shared workers

Browsers provide two worker options:

  • dedicated workers: a single script launched, used, and terminated by another

  • shared workers: a single script accessible to multiple scripts in different windows, iframes, or workers

Each script communicating with a shared worker passes a unique port, which a shared worker must use to pass data back. However, shared workers aren’t supported in IE or most mobile browsers, which makes them unusable in typical web projects.

Client-side worker limitations

A worker runs in isolation to the main thread and other workers; it can’t access data in other threads unless that data is explicitly passed to the worker. A copy of the data is passed to the worker. Internally, JavaScript uses its structured clone algorithm to serialize the data into a string. It can include native types such as strings, numbers, Booleans, arrays, and objects, but not functions or DOM nodes.

Browser workers can use APIs such as console, Fetch, XMLHttpRequest, WebSocket, and IndexDB. They can’t access the document object, DOM nodes, localStorage, and some parts of the window object, since this could lead to the concurrency conflict problems JavaScript solved with single threading — such as a DOM change at the same time as a redirect.

IMPORTANT: workers are best used for CPU-intensive tasks. They don’t benefit intensive I/O work, because that’s offloaded to the browser and runs asynchronously.

How to use a client-side web worker

The following demonstration defines src/index.js as the main script, which starts the clock and launches the web worker when a user clicks the start button. It defines a Worker object with the name of the worker script at src/worker.js (relative to the HTML file):


const worker = new Worker("./src/worker.js");

An onmessage event handler follows. This runs when the worker sends data back to the main script — typically when the calculation is compete. The data is available in the event object’s data property, which it passes to the endDiceRun() function:


worker.onmessage = function(e) {
  endDiceRun(e.data);
};

The main script launches the worker using its postMessage() method to send data (an object named cfg):


worker.postMessage(cfg);

The src/worker.js defines worker code. It imports src/dice.js using importScripts() — a global worker method which synchronously imports one or more scripts into the worker. The file reference is relative to the worker’s location:

importScripts('./dice.js');

src/dice.js defines a diceRun() function to calculate the throwing statistics:


function diceRun(runs = 1, dice = 2, sides = 6) {
  const stat = [];

  while (runs > 0) {
    let sum = 0;

    for (let d = dice; d > 0; d--) {
      sum += Math.floor(Math.random() * sides) + 1;
    }
    stat[sum] = (stat[sum] || 0) + 1;
    runs--;
  }

  return stat;
}

Note that this is not an ES module (see below).

src/worker.js then defines a single onmessage() event handler. This runs when the main calling script (src/index.js) sends data to the worker. The event object has a .data property which provides access to the message data. In this case, it’s the cfg object with the properties .throws, .dice, and .sides, which get passed as arguments to diceRun():

onmessage = function(e) {

  
  const cfg = e.data;
  const stat = diceRun(cfg.throws, cfg.dice, cfg.sides);

  
  postMessage(stat);

};

A postMessage() function sends the result back to the main script. This calls the worker.onmessage handler shown above, which runs endDiceRun().

In summary, threaded processing occurs by sending message between the main script and the worker:

  1. The main script defines a Worker object and calls postMessage() to send data.
  2. The worker script executes an onmessage handler which starts a calculation.
  3. The worker calls postMessage() to send data back to the main script.
  4. The main script executes an onmessage handler to receive the result.

worker message calls

Web worker error handling

Unless you’re using an old application, developer tools in modern browsers support web worker debugging and console logging like any standard script.

The main script can call a .terminate() method to end the worker at any time. This may be necessary if a worker fails to respond within a specific time. For example, this code terminates an active worker if it hasn’t received a response within ten seconds:


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


const workerTimer = setTimeout(() => worker.terminate(), 10000);


worker.onmessage = function(e) {

  
  clearTimeout(workerTimer);

};


worker.postMessage({ somedata: 1 });

Worker scripts can use standard error handling techniques such as validating incoming data, try, catch, finally, and throw to gracefully handle issues as they arise and report back to the main script if required.

You can detect unhandled worker errors in the main script using these:

  • onmessageerror: fired when the worker receives a data it cannot deserialize

  • onerror: fired when an JavaScript error occurs in the worker script

The returned event object provides error details in the .filename, .lineno, and .message properties:


worker.onerror = function(err) {
  console.log(`${ err.filename }, line ${ err.lineno }: ${ err.message }`);
}

Client-side web workers and ES modules

By default, browser web workers are not able to use ES modules (modules that use the export and import syntax).

The src/dice.js file defines a single function imported into the worker:

importScripts('./dice.js');

Somewhat unusually, the src/dice.js code is also included in the main src/index.js script, so it can launch the same function as worker and non-worker processes. src/index.js loads as an ES module. It can’t import the src/dice.js code, but it can load it as an HTML

© 2022 The Programmer News Hubb All rights reserved.

Use of these names, logos, and brands does not imply endorsement unless specified. By using this site, you agree to the Privacy Policy and Terms & Conditions.

Navigate Site

  • Home
  • Technical Insights
  • Tricks & Tutorial
  • Contact

Newsletter Sign Up.

No Result
View All Result
  • Home
  • Technical Insights
  • Tricks & Tutorial
  • Contact

© 2022 The Programmer News Hubb All rights reserved.