Web workers and multithreading in JavaScript

Anton Ioffe - August 30th 2023 - 15 minutes read

Understanding JavaScript's Landscape: The Single-thread and Beyond

JavaScript is single-threaded, which means it can execute only one operation at a time. This characteristic makes JavaScript different from many other programming languages, and also stands at the core of its approach to asynchronous operations and concurrency. From this follows very specific patterns for handling time-consuming tasks, as we do not wish for our application to become unresponsive in the meantime.

Take, for instance, a simple task like making a request to an API for data. We cannot afford to stand still while the data is loading; instead, we would want our program to continue with tasks that follow. This is where JavaScript's asynchronicity shines.

At the core of JavaScript's asynchronous magic lies the Event Loop, along with the Call Stack, Callback Queue, and, introduced with ES6, the Job Queue.

Call Stack is where the JavaScript engine keeps track of what function is currently running and what functions are waiting to be run. Here, the function that is currently being executed sits on top of the stack, and when finished, pops off to provide space for the following.

In synchronous case, everything appears pretty straightforward: the functions are pushed to the Call Stack and popped off once they're completed, one after another. But what happens when we introduce asynchronous tasks?

When we run an asynchronous operation in JavaScript like setTimeout or AJAX request via fetch, it is handled by the engine's WebAPIs. The function is not immediately pushed to the Call Stack; instead, it's held within the WebAPIs for some time, then moved to the Callback Queue where it waits to be pushed to the Call Stack.

However, it cannot be pushed to the Call Stack as long as there are any synchronous operations there. Thus, enters our star - the Event Loop. The Event Loop's job is to continuously check whether the Call Stack is empty. When it's empty, the Event Loop pushes the next function from the Callback Queue to the Call Stack.

There is, however, one more piece of the puzzle, and it is the Job Queue. The Job Queue was introduced with ES6, as a part of Promise implementation. It holds callbacks from Promise .then and .catch. These callbacks have priority over the Callback Queue, meaning as soon as Promise is resolved, its callback is pushed to the Call Stack at the very next tick of the Event Loop, disregarding any other callbacks that might be waiting in queue.

Let's illustrate these concepts with a bit of code:

console.log('1');

setTimeout(function afterTwoSeconds() {
    console.log('2');
}, 2000)

Promise.resolve().then(function immediate() {
    console.log('3');
});

console.log('4');

Can you guess the output? It's 1, 4, 3, 2.

The fact that JavaScript is single-threaded does not make it slow or less powerful for handling concurrent operations. On the contrary, combined with the Event Loop, Call Stack, Callback Queue, and Job Queue, this single thread works with impressive efficiency, providing non-blocking, event-driven programming capability.

Now, try to imagine a scenario where multiple users are trying to access and update the same data on a server in real time. What would be potential strategies for managing these operations? How would JavaScript handle this in a highly concurrent way?

As you can see, understanding the event-driven, asynchronous nature of JavaScript is a crucial part of mastering the language, both in standard JavaScript and TypeScript. ${' '}

Practical Applications of Web Workers: Implementation, Use Cases, and DOM Interaction

Web workers are a fantastic feature of JavaScript that allows us to create multithreaded programs. They provide a simple means for web content to run scripts in background threads. The worker thread can perform tasks without interfering with the user interface.

However, a Worker may not directly manipulate the DOM. This will become more evident as we go deeper into this discussion.

How to Implement Web Workers in JavaScript

Here is a standard approach of implementing a web worker in JavaScript:

const myWorker = new Worker(URL.createObjectURL(new Blob(['('+ workerFunction.toString() +')()'], {type: 'text/javascript'})));

// Define the worker function
function workerFunction(){
    // Listen for incoming messages from the main thread
    onmessage = function(event) {
        // Send messages from the worker to the main thread
        postMessage('Worker says: '+ event.data);
    }
}

Examples of Multithreading in Web Applications

One of the effective use of web workers in JavaScript is to handle computationally expensive tasks in the background. Imagine a scenario where you are computing the nth Fibonacci number on a button click. Without using a worker, the UI will freeze relentlessly until the operation is complete. Let's provide a solution for such scenarios with a JavaScript example that uses web workers to compute the Fibonacci sequence:

// Message received from main thread
self.onmessage = function(event) {
    // Calculate Fibonacci number
    const result = fibonacci(event.data);
    // Send result back to main thread
    postMessage(result);
}

// Function to calculate nth Fibonacci number
function fibonacci(num) {
    return num <= 1 ? num : fibonacci(num - 1) + fibonacci(num - 2);
}

Use Cases for Web Workers

Web workers in JavaScript can be highly beneficial in certain scenarios. These include:

  • Running long scripts: Since JavaScript is single-threaded, multiple scripts cannot run concurrently. However, web workers run separately from the main execution thread, rendering the page responsive while handling heavy computations.

  • Performing computationally intensive calculations in the background: This ensures the main thread is not blocked, keeping the UI responsive.

When and Where Not to Use Web Workers

Web workers are most useful for tasks that are heavy on computations and don't need to manipulate the DOM or involve multiple AJAX requests. However, one must be mindful that the overhead of creating a worker can previously be insignificant and may, in fact, offset the performance benefits you get from using them for trivial tasks.

Let's look at an example in JavaScript where web workers are misused leading to performance inefficiencies:

// Unnecessary worker creation and termination
self.onmessage = function(event) {
    // Unnecessary calculation without practical use
    let total = 0;
    for (let i=0; i< 10000000; i++){
        total += Math.sqrt(i) * Math.random();
    }
    // Send total back to main thread
    postMessage(total);
}

In the above example, the worker is excessively burdened with a calculation that doesn't have any practical use, thereby reducing the performance efficiency of the web worker.

Web Workers and the DOM Interaction

A common misunderstanding while using web workers in JavaScript is to imply it can manipulate the DOM, which it cannot. Web workers do not have access to the following JavaScript objects:

  • The window object
  • The document object
  • The parent object

Let's look at a JavaScript example, where we mistakenly attempt to manipulate the DOM using a Web Worker:

self.onmessage = function(event) {
    // Attempt to access a DOM element (this will fail)
    const mainElement = document.getElementById('main');
    // Attempt to update text content of the DOM element (this will fail)
    mainElement.textContent = 'Web Worker updated text';
}

Executing the above code in a Web Worker would result in an error message stating that 'document is undefined'. This is because a worker is executed in an isolated thread and the variables of the main thread are not accessible to it.

In conclusion, web workers are a practical option to avoid hindering page responsiveness when constructing web applications with heavy computations in JavaScript. However, they should be used wisely, bearing in mind that they cannot interact with DOM and the overhead of creating a worker may be significant for trivial tasks. Correct use of Web Workers can contribute to an improved performance of your web application. Happy coding!

Concurrency vs. Multithreading: The Role of Web Workers

In an age of high-performance optimization, any capacity to handle tasks concurrently can be a game-changer for web applications. Understanding how to employ web technologies like web workers effectively will help developers to architect efficient, high-performance web systems. Let's dive deep into understanding Concurrent processing and multithreading in the world of JavaScript and TypeScript, emphatically emphasizing on the role of web workers.

First and foremost, it is important to differentiate between concurrent and multithreaded processes. In a concurrent environment, tasks start, run, and complete in overlapping time periods. However, they do not necessarily run simultaneously. This is made possible through an operation known as task switching where a single processor handles multiple tasks by constantly switching between them.

On the other hand, multithreading is a specialized form of concurrent processing where several threads within a single process execute independently of each other. But the JavaScript engine works on a single thread, meaning it has a single call stack and memory heap. So where does that leave us for multithreading?

This is where the role of web workers comes in. Web Workers in JavaScript, kind of, provides a way around the single-threaded nature of JavaScript to achieve multithreading. Web workers are able to execute code off the main thread, preventing UI blocking and resulting in smoother performance of complex operations. They run in the background, on a separate thread, free from any form of interaction with the DOM.

Here is an example demonstrating how you can create a web worker in JS:

// worker.js
self.addEventListener('message', function(e) {
  self.postMessage(e.data);
}, false);

// main.js
// Check if web workers are available
if (window.Worker) {
  let worker = new Worker('worker.js');

  worker.onmessage = function(e) {
    console.log('Received: ' + e.data);
  }

  worker.postMessage('Hello Web Worker');
}

Web workers cannot directly manipulate the DOM, but they can communicate with the main thread via a system of message passing, to influence the interface indirectly. Performance improvements can be achieved by offloading heavy computational tasks or network requests to web workers.

Nevertheless, It's vital to bear in mind that spawning new workers entails a fair bit of overhead in terms of memory and performance. Therefore it's best to utilize workers judiciously, especially on resource-constrained devices.

When approaching the decision whether to use concurrency via task switching or multithreading with web workers, it's vital to examine the specific needs and resources of your project. Considering these choices reflect key trade-offs in terms of memory usage, CPU usage, and code complexity.

Key considerations driving this decision include:

  • The performance capabilities of the user's device
  • The complexity of the tasks to be executed
  • The expected traffic volume to your application

This analysis brings us to a question - How could you modify the structure of your web application to strategically switch between the use of the main thread and worker threads based on the current load of the application?

Remember, understanding the differences and trade-offs in concurrency and multithreading can empower you to write more performant and efficient code.

Beyond the Basics: Web Workers, Performance, and Parallelism

Web Workers, as a significant component of JavaScript, handle complex tasks, transcend the limitations of single-threading, and step into the realm of multithreading.

Concurrent Operations Via Web Workers

JavaScript leverages Web Workers to efface the illusion of multithreading. These workers operate scripts concurrently within separate JavaScript runtime environments.

Consider the spawning of several Web Workers:

// File: Worker.js
self.addEventListener('message', function(e) {
    // Perform an intense operation on received data
    const result = performHeavyComputation(e.data);
    postMessage(result);
}, false);

// File: Main.js
let workers = [];
for (let i = 0; i < 4; i++) {
    workers[i] = new Worker('path/to/Worker.js');
    workers[i].postMessage(someData);
    workers[i].addEventListener('message', function(e) {
        console.log('Result received from worker', e.data);
    }, false);
}

Creating too many Web Workers at once could hamper performance. This fault is analogous to excessive thread spawning in single-threaded programming. Below is an example showcasing this common gaffe:

let workers = [];
for (let i = 0; i < 1000; i++) {
    workers[i] = new Worker('path/to/Worker.js');
}
// Excessive workers could potentially decrease performance

Web Workers and Worker Threads: A Comparative Analysis

Web Workers in JavaScript perform in varying instances of JavaScript's runtime, manifesting an illusion of multithreading. These workers should not be perplexed with worker threads, native to low-level languages, that operate differently.

The Influence of Web Workers on Parallelism

Web Workers allow parallel execution of codes independent of each other. However, developers should be cautious not to misconstrue concurrent programming and parallel execution. While concurrency grants overlapping time periods for tasks, parallelism involves executing multiple tasks simultaneously, which is unviable in JavaScript.

Time Complexity and Multithreading

The theoretical time complexity of an algorithm remains unaltered with multithreading. However, wall clock time, or the time perceived by users, can be optimized through successful multithreading.

Performance: Single-Threading vs Multithreading

Performance metrics oscillate between single-threading and multithreading based on task complexity. Sequential processes may fail to take full advantage of a device's capacity when compared to parallel processing with Web Workers. The following code samples illustrate the different performance implications of single-threading and multithreading.

For single-threading:

// Time-consuming computational task
for (let i = 0; i < 1e7; i++) {
    Math.sqrt(i);
}

For multithreading:

// File: Worker.js
self.addEventListener('message', function(e) {
    for(let i=0; i<e.data; i++){
        Math.sqrt(i);
    }
    postMessage('Done');
}, false);

// File: Main.js
let worker = new Worker('path/to/Worker.js');
worker.postMessage(1e7);
worker.addEventListener('message', function(e) {
    console.log('Message received from worker', e.data);
}, false);

Understanding Concurrent Programming

Although JavaScript is inherently single-threaded, Web Workers have introduced the possibility of concurrent programming. However, it's imperative to understand that concurrency doesn't imply parallel execution of tasks; instead, it means tasks are running in overlapping time frames. Here's an example that highlights how an inappropriate use of Web Workers for simple tasks can influence performance adversely:

// Initializing a new Worker for a straightforward task like this can impede performance
const worker = new Worker('path/to/Worker.js');
worker.postMessage('Hello, World!');

To conclude, Web Workers offer a taste of concurrent computing to JavaScript applications. However, understanding the subtleties of concurrent processing and the potential of multithreading is critical to effectively harness their power.

Web Assembly: Does it Support Multithreading?

Introduction to Multithreading

Web development practices commonly employ languages like JavaScript or TypeScript, following the single-threaded event-loop model for managing concurrent operations. However, there can be cases where enhanced performance becomes a necessity. To understand this, a comparison with Java, a language with built-in multithreading support, can be helpful.

In Java, multithreading enables concurrent execution of two or more parts of a program for maximum utilization of CPU, thereby improving performance. In contrast, languages like JavaScript and TypeScript don't have an immediate option for multithreading as built into their natural state. They adhere to single-threaded, data-race-free models.

Understanding Concurrency in a Web Environment

In a web landscape, two concepts handle concurrency and parallelism: web workers. In JavaScript or TypeScript, web workers enable data transfer or duplication between each other and the main thread. But, direct data sharing isn't possible.

This restriction is a necessary measure designed to avoid data races and ensure deterministic JavaScript execution in a single-threaded environment.

// Main thread
const myWorker = new Worker('worker.js');

myWorker.postMessage([first.value, second.value]);

myWorker.onmessage = function(e) {
  const receivedTextContent = e.data;
}
// worker.js - Web Worker 
onmessage = function(e) {
  const calculatedResult = e.data[0] * e.data[1];
  postMessage(calculatedResult);
}

Through the above example, we can see that communication between the main thread and worker thread takes place without direct sharing of the data.

A common mistake is trying to directly access data from the main thread inside a web worker.

// worker.js - WRONG way of handling data in Web Worker 
let mainThreadData = self.mainThreadData; // BAD!

The above code is an example of what NOT to do. Direct access of data from the main thread inside a Worker can lead to chaos. Mutations in the data could occur unexpectedly while still in processing, leading to unpredictable results and potential crashes.

WebAssembly and Threads Support

WebAssembly might seem like a promising solution designed for executing code at near-native speeds. It might give an impression that it brings the power of multithreading to enhance application performance through thread-level parallelism. However, WebAssembly's ability to manage threads depends significantly on the host environment.

Key Challenges and Limitations to Consider

Currently, TypeScript lacks real multithreading support, despite being a superset of JavaScript. TypeScript's static types system could provide rich tooling to help developers prevent data races at compile time in the event of JavaScript runtime supporting multithreading.

Additionally, WebAssembly as of now does not fully integrate multithreading within web environments. Yet, there are ongoing discussions indicating possible progress in this area.

Conclusion

Multithreading could bring about massive changes to web development practices, should these discussions turn into concrete changes. This prospective transition presents exciting new challenges and possibilities for development, and developers need to keep themselves updated with the latest advancements in WebAssembly.

However, until such advancements become a reality, developers need to strategize and find other means to manage multithreading issues in their current projects using JavaScript or TypeScript. The implication of this challenge is the need for careful data management to maintain integrity and drive the performance-driven outcomes that multithreading promises.

As your challenge, think of ways you could adapt your current JavaScript or TypeScript projects to maximize the effective use of web workers, pending the integration of full multithreading. How can you ensure the integrity of complex data structures in such a scenario?

Node.js and the Multithreading Conundrum

Node.js has traditionally been associated with a single-threaded, event-driven architecture. This design choice is perfectly suited to tasks involving I/O (Input/Output) operations, but presents problems when it comes to CPU-intensive tasks due to the potential blocking of that single thread. However, Node.js is not lacking in multithreading capabilities, as we'll explore in this article.

Examining the Single-threaded Nature

The majority of the Node.js code you write, known as user code, is executed on a single thread. This contrasts with the language in which Node.js is written, C++, which inherently supports multithreading. Through a feature called the libuv library, the Node.js runtime enables JavaScript to interact with C++ threads for asynchronous operations.

An example of your Node.js code might be:

const fs = require('fs');

// Reading the file in non blocking way
fs.readFile('path/to/file', 'utf8', (err, data) => {
    console.log(data);
});

console.log('After calling readFile');

Here, console.log('After calling readFile'); executes before fs.readFile(...), even though it's written after. This exemplifies the non-blocking, event-driven nature of the Node.js runtime. The fs.readFile code is delegated to another thread by libuv, thus allowing your script to continue executing user code, for instance, the console.log() statement.

Now, let's delve into the multithreading capabilities of Node.js.

Worker Threads

Worker threads, initially introduced in Node.js 10.5.0 under an experimental flag and later stabilized in Node.js 12, allow for CPU intensive JavaScript execution in parallel. They provide JavaScript hands-on access to multithreaded functionality that libuv just wasn't able to.

The following example demonstrates how to run code in a Worker thread:

const { Worker } = require('worker_threads');

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

// Sending a message to the worker
worker.postMessage('Hello worker');

// Receiving a message from the worker
worker.on('message', (message) => {
    console.log('Message from worker: ', message);
});

To note, the postMessage and 'message' event APIs here are unique to worker threads and aren't part of the standard JavaScript syntax.

Also, an important aspect of using worker threads is the handling of error and exit events. Without this, your program may hang without properly terminating.

worker.on('error', (error) => {
    console.log('An error occurred: ', error);
});

worker.on('exit', (exitCode) => {
    console.log('Worker stopped with exit code ', exitCode);
});

Finally, consider the memory model of worker threads. Each worker thread maintains its own memory space, which means modifications to the global scope of the main thread are not seen by worker threads or vice versa.

This leads us to a question: how can data effectively be shared across threads? A possible approach is the use of SharedArrayBuffer or MessageChannel, which come with their own set of challenges like potential race conditions. This is an exiting topic to delve into and understand!

Harnessing the power of single-threaded event loop and multithreaded capabilities is vital to leveraging Node.js to its full potential. Though these features can significantly improve your application's performance, if implemented incorrectly they could potentially deteriorate the structure of your application. Is there a way to harmonize these two seemingly contrasting paradigms? Try exploring this yourself. It's a challenge that'll truly help you understand the ins and outs of Node.js and we will be eager to hear about what you discover!

Don't Get Left Behind:
The Top 5 Career-Ending Mistakes Software Developers Make
FREE Cheat Sheet for Software Developers