5 use cases of worker threads in JavaScript

Mayank Choubey
Tech Tonic
Published in
8 min readApr 26, 2024

--

Introduction

JavaScript, by its nature, is single-threaded. This means that only one task can be executed at a time. While this model works well for many web applications, it can become a bottleneck for tasks that require intensive computations or frequent communication with external servers. This is where worker threads come in.

Worker threads are a powerful feature available in modern web browsers (and Node.js, Deno, Bun too) that allow us to spawn independent threads of execution within the same JavaScript environment. These worker threads run concurrently with the main thread, enabling our application to perform CPU-bound operations or handle network requests without blocking the UI.

The advantages of using worker threads are:

  • Improved responsiveness: By offloading long-running tasks to worker threads, the main thread remains free to handle user interactions and update the UI. This leads to a smoother and more responsive user experience.
  • Enhanced performance: Worker threads can make use of multiple cores on modern processors, allowing for parallel execution of computationally expensive tasks.
  • Efficient data sharing: Unlike child processes, worker threads share memory with the main thread. This enables efficient transfer of large data structures between the threads, reducing the need for expensive serialization and deserialization.
  • Modular Code: Worker threads advocates modular code organization. We can isolate complex tasks within separate worker scripts, improving code maintainability and reusability.

Everything has some disadvantages too, including worker threads:

  • Increased complexity: Introducing worker threads adds complexity to the codebase. We’ll need to manage communication channels between threads and handle potential race conditions that can arise when accessing shared resources.
  • Not ideal for I/O-bound tasks: While worker threads excel at CPU-bound tasks, they don’t offer significant benefits for I/O-bound operations like network requests. The asynchronous nature of JavaScript’s event loop is often more efficient in such cases.

Now that we’ve understood (or refreshed) what worker threads are for, let’s move on to their five common use cases.

Use case 1 — CPU-intensive calculations

Imagine we have a complex mathematical formula to calculate for multiple data points. Running this directly on the main thread would block the UI and make our web application unresponsive. These kind of tasks are ideal for worker threads.

Consider the following example:

function calculatePi(iterations) {
let pi = 0;
for (let i = 0; i < iterations; i++) {
const sign = (i % 2 === 0) ? 1 : -1;
pi += sign / (2 * i + 1);
}
return pi * 4;
}

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

worker.postMessage({ iterations: 1000000 });

worker.onmessage = (event) => {
const calculatedPi = event.data;
console.log("Calculated Pi:", calculatedPi);
};

// worker.js

self.addEventListener('message', (event) => {
const pi = calculatePi(event.data.iterations);
self.postMessage(pi);
});

The calculatePi function performs the calculation for PI using a Monte Carlo method. The main thread creates a new worker instance referencing a separate JavaScript file (worker.js). It sends a message to the worker with the number of iterations for calculating PI. The worker receives the message, calculates PI using the same function, and then posts the result back to the main thread. The main thread listens for messages from the worker and logs the calculated PI value.

This approach allows the main thread to remain responsive while the worker thread performs the heavy computation in the background.

Use case 2 — Image/video processing

Let’s say we want to allow users to upload images to our web application and apply a grayscale filter before displaying them. Processing the image directly in the main thread could cause a lag while the filter is applied. Again, a CPU intensive work where worker threads can save the day.

Check the following example:

const uploadInput = document.getElementById('upload');

uploadInput.onchange = (event) => {
const imageFile = event.target.files[0];
const reader = new FileReader();

reader.onload = (event) => {
const imageData = new Image();
imageData.onload = () => {
const worker = new Worker('./imageWorker.js');
worker.postMessage({ imageData: imageData });

worker.onmessage = (event) => {
const processedImageData = event.data;
const processedImage = document.createElement('img');
processedImage.src = processedImageData;
document.body.appendChild(processedImage);
};
};
imageData.src = event.target.result;
};

reader.readAsDataURL(imageFile);
};

// imageWorker.js

self.addEventListener('message', (event) => {
const imageData = event.data.imageData;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = imageData.width;
canvas.height = imageData.height;
context.drawImage(imageData, 0, 0);
const imageDataCopy = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageDataCopy.data;
for (let i = 0; i < data.length; i += 4) {
const average = (data[i] + data[i + 1] + data[i + 2]) / 3;
data[i] = average; // Red
data[i + 1] = average; // Green
data[i + 2] = average; // Blue
}
context.putImageData(imageDataCopy, 0, 0);
self.postMessage(canvas.toDataURL());
});

The above code allows users to upload an image. The FileReader reads the uploaded image file and creates an Image object. A worker is created referencing the imageWorker.js script. The image data is sent to the worker thread. Inside the worker, a canvas element is created to hold a copy of the image. The grayscale filter is applied by iterating over the image data and converting each pixel to its grayscale equivalent. The processed image data is then converted to a data URL and sent back to the main thread. The main thread receives the data URL and creates a new image element to display the grayscale version.

By using a worker thread, the image processing happens in the background, and the UI remains responsive while the filter is applied.

Use case 3 — Data analysis

This time, we have a large dataset of customer information, and we want to filter and sort it based on different criteria. Performing these operations on the main thread could significantly slow down the web application, especially if the dataset is extensive.

The following example show how to use worker threads for this use case:

const data = [
{ name: "Alice", age: 25, city: "New York" },
{ name: "Bob", age: 30, city: "Los Angeles" },
{ name: "Charlie", age: 28, city: "Chicago" },
// ... (large dataset)
];

const filterByCity = (city) => {
return new Worker('./dataWorker.js', { workerScripts: ['./data-filter.js'] });
};

const sortByAge = () => {
return new Worker('./dataWorker.js', { workerScripts: ['./data-sort.js'] });
};

const displayData = (filteredData) => {
// Update UI with filtered data
console.log(filteredData);
};

const filterButton = document.getElementById('filter-button');
const sortButton = document.getElementById('sort-button');

filterByCity("New York").postMessage({ data: data, filter: "city", value: "New York" });

filterButton.addEventListener('click', () => {
const selectedCity = document.getElementById('city-select').value;
filterByCity(selectedCity).postMessage({ data: data, filter: "city", value: selectedCity });
});

sortButton.addEventListener('click', () => {
const worker = sortByAge();
worker.postMessage({ data: data });
worker.onmessage = (event) => {
displayData(event.data);
};
});

// dataWorker.js (empty template)

self.addEventListener('message', (event) => {
// Logic to handle data processing based on received message (filter or sort)
// Import the appropriate script (data-filter.js or data-sort.js)
importScripts(event.data.workerScripts);
// Use the imported functions to process the data from event.data.data
const processedData = processData(event.data.data);
self.postMessage(processedData);
});

// data-filter.js (example)

function filterData(data, filter, value) {
return data.filter((item) => item[filter] === value);
}

// data-sort.js (example)

function sortData(data) {
return data.sort((a, b) => a.age - b.age);
}

This example showcases a basic structure for handling data analysis tasks in worker threads. The main thread holds the original data and functions to trigger filtering and sorting operations. The filterByCity and sortByAge functions create new worker instances, each referencing the dataWorker.js script. The worker script itself is empty and relies on additional scripts (data-filter.js and data-sort.js) imported based on the message received. The message sent to the worker includes the data and the type of operation (filter or sort) with its arguments. The imported scripts (data-filter.js and data-sort.js) contain the logic for filtering and sorting the data, respectively. (These scripts are not included here but represent the actual processing logic). The processed data is then sent back to the main thread, which can update the UI accordingly.

Once again, this approach allows the main thread to remain responsive while the worker threads perform the data analysis in the background.

Use case 4 — Background tasks

Imagine we have a web application that allows users to save their work in progress. Continuously saving data to the server while the user edits can be inefficient and could potentially slow down the UI. A worker thread can be used to handle the background save functionality.

const saveButton = document.getElementById('save-button');
const editor = document.getElementById('editor');

saveButton.addEventListener('click', () => {
const dataToSave = editor.value;
const worker = new Worker('./backgroundWorker.js');
worker.postMessage({ data: dataToSave });

worker.onmessage = (event) => {
if (event.data === 'success') {
console.log("Data saved successfully!");
} else {
console.error("Error saving data!");
}
};
});

// backgroundWorker.js

self.addEventListener('message', (event) => {
const data = event.data;
// Simulate asynchronous file save operation (replace with your actual logic)
setTimeout(() => {
console.log("Saving data...");
const success = Math.random() > 0.5; // Simulate random success/failure
self.postMessage(success ? 'success' : 'failure');
}, 2000);
});

The saveButton triggers the background save process when clicked. The current editor content (dataToSave) is sent to a worker thread initiated with backgroundWorker.js. The worker thread simulates an asynchronous file save operation (replace this with your actual logic for saving data to a database or file system). After a simulated delay, the worker sends a message back to the main thread indicating success or failure. The main thread receives the message and displays a success or error message accordingly.

This example demonstrates how a worker thread can be used to handle a long-running background task (saving data) without affecting the responsiveness of the UI (the editor remains usable while the save is happening).

Use case 5 — Real-time applications

Real-time applications require constant communication with a server to keep the UI updated with the latest information. Worker threads can be beneficial for managing these connections and processing incoming data without blocking the main thread responsible for rendering the UI.

Check the following piece of code:

const connectButton = document.getElementById('connect-button');
const chatWindow = document.getElementById('chat-window');

connectButton.addEventListener('click', () => {
const worker = new Worker('./chatWorker.js');
worker.postMessage({ url: 'ws://your-chat-server.com' });

worker.onmessage = (event) => {
const message = event.data;
const chatLine = document.createElement('p');
chatLine.textContent = message;
chatWindow.appendChild(chatLine);
};
});

// chatWorker.js

self.addEventListener('message', (event) => {
const url = event.data.url;
const ws = new WebSocket(url);

ws.onmessage = (event) => {
self.postMessage(event.data);
};
});

The connectButton initiates the real-time chat connection. A worker thread is created with chatWorker.js. The worker establishes a WebSocket connection to the chat server using the provided URL. Whenever a new message arrives through the WebSocket, the worker thread simply relays it back to the main thread using postMessage. The main thread receives the message and updates the chat window UI with the new content.

The above example demonstrates how a worker thread can handle the WebSocket connection and message processing, allowing the main thread to focus on UI rendering and remain responsive while new chat messages are received. In a real application, we would likely add logic to the main thread to format or display the message data in a more user-friendly way.

That’s all about it! I hope this has been of help to you.

--

--