JavaScript - JavaScript Web Workers and Multithreading
Introduction
JavaScript is traditionally known as a single-threaded programming language. This means that JavaScript code running on the main browser thread generally executes one task at a time. When a JavaScript program performs a heavy operation, such as processing a large amount of data, performing complex calculations, or manipulating thousands of objects, it can keep the main thread busy.
When the main thread is blocked, the web page may become slow or unresponsive. Buttons may stop responding, animations may freeze, and the user may experience delays.
Web Workers provide a solution to this problem. A Web Worker allows JavaScript code to run in a separate background thread. The main thread can continue handling the user interface while the worker performs computationally intensive tasks.
What Are Web Workers?
A Web Worker is a browser feature that allows JavaScript to execute in a background thread independently of the main JavaScript execution thread.
Normally, JavaScript works like this:
Main Thread
|
|-- Execute JavaScript
|-- Update HTML
|-- Handle user interaction
|-- Perform calculations
|
+-- All tasks share the same thread
With a Web Worker:
Main Thread Worker Thread
| |
|-- User Interface |-- Heavy calculation
|-- User interaction |-- Data processing
|-- DOM operations |-- Background task
| |
+---------- Messages -----------+
The worker performs its work separately and communicates with the main thread through messages.
Why Are Web Workers Needed?
Consider a webpage that needs to perform a very large calculation.
let total = 0;
for (let i = 0; i < 1000000000; i++) {
total += i;
}
console.log(total);
This loop can consume a significant amount of processing time.
If it runs on the main thread, the browser may not be able to respond quickly to user actions while the calculation is running.
For example, the following may be affected:
-
Clicking buttons
-
Scrolling
-
Typing into input fields
-
Animations
-
Page rendering
-
Other JavaScript operations
A Web Worker can move this computational work away from the main thread.
Types of Web Workers
There are several worker-related mechanisms in modern web applications.
1. Dedicated Worker
A Dedicated Worker is associated with a single page or script that created it.
It is the most commonly used type of Web Worker.
const worker = new Worker("worker.js");
The main page communicates directly with this worker.
2. Shared Worker
A Shared Worker can potentially be accessed by multiple browsing contexts, such as different windows or tabs, when they are allowed to share the same worker.
It is created using:
const worker = new SharedWorker("shared-worker.js");
Shared Workers are useful when multiple pages need to communicate with a common background process.
3. Service Worker
A Service Worker is another type of worker designed primarily for network-related and application-level tasks.
Service Workers are commonly used for:
-
Offline web applications
-
Caching
-
Background network handling
-
Push notifications
-
Progressive Web Apps
A Service Worker has a different lifecycle and purpose from a normal Dedicated Worker.
Creating a Web Worker
Suppose we have two files:
index.html
worker.js
The main JavaScript file can create a worker:
const worker = new Worker("worker.js");
The browser loads worker.js and executes it in a separate worker context.
The worker does not automatically have access to the variables or functions defined in the main page.
Communication Between Main Thread and Worker
The main thread and worker communicate using messages.
The primary methods are:
postMessage()
and
onmessage
The main thread can send data to the worker using postMessage().
worker.postMessage("Hello Worker");
The worker can receive the message:
self.onmessage = function(event) {
console.log(event.data);
};
Here:
event.data
contains the information sent by the main thread.
Complete Basic Example
Main JavaScript
const worker = new Worker("worker.js");
worker.postMessage(10);
worker.onmessage = function(event) {
console.log("Result:", event.data);
};
worker.js
self.onmessage = function(event) {
const number = event.data;
const result = number * number;
self.postMessage(result);
};
The process works as follows:
Main Thread
|
| postMessage(10)
v
Worker
|
| Calculate 10 * 10
v
Worker
|
| postMessage(100)
v
Main Thread
|
| Receive result
The main thread does not need to perform the calculation itself.
The postMessage() Method
postMessage() is used to send information from one execution context to another.
For example:
worker.postMessage({
number: 25,
operation: "square"
});
The worker receives the object:
self.onmessage = function(event) {
console.log(event.data.number);
console.log(event.data.operation);
};
The output would be:
25
square
The data passed through postMessage() is generally handled using the structured clone mechanism, rather than simply sharing the same JavaScript object reference.
Sending Data Back to the Main Thread
The worker can return data using:
self.postMessage()
Example:
self.onmessage = function(event) {
const value = event.data;
const result = value * 5;
self.postMessage(result);
};
The main thread receives the result:
worker.onmessage = function(event) {
console.log(event.data);
};
If the original value was 20, the worker returns:
100
Using onmessage
The onmessage event is triggered when a message is received.
Main thread:
worker.onmessage = function(event) {
console.log("Message from worker:", event.data);
};
Worker:
self.onmessage = function(event) {
console.log("Message from main thread:", event.data);
};
The event.data property contains the transferred data.
Using addEventListener()
Instead of assigning onmessage, you can use addEventListener().
worker.addEventListener("message", function(event) {
console.log("Received:", event.data);
});
Inside the worker:
self.addEventListener("message", function(event) {
console.log(event.data);
});
This approach can be useful when multiple event listeners need to be registered.
Performing Heavy Calculations
One of the most important applications of Web Workers is performing CPU-intensive operations.
For example, suppose an application needs to calculate a large number of values.
The worker might contain:
self.onmessage = function(event) {
const limit = event.data;
let total = 0;
for (let i = 1; i <= limit; i++) {
total += i;
}
self.postMessage(total);
};
The main thread can send:
worker.postMessage(100000000);
The worker performs the calculation and returns the result.
This keeps the main interface more responsive than performing the same heavy calculation directly on the main thread.
Web Workers and the DOM
An important limitation of Web Workers is that they do not directly access the page's DOM.
For example, this will not work inside a normal Web Worker:
document.getElementById("result").textContent = "Hello";
The worker does not have the normal window and document objects available in the same way as the main browser thread.
Instead, the worker sends a message:
self.postMessage("Hello");
The main thread receives it and updates the DOM:
worker.onmessage = function(event) {
document.getElementById("result").textContent = event.data;
};
This separation is important because the worker performs background processing while the main thread controls the user interface.
Terminating a Worker
A worker continues to exist until it is terminated or its execution naturally ends.
The main thread can terminate a worker using:
worker.terminate();
For example:
const worker = new Worker("worker.js");
worker.terminate();
Once terminated, the worker cannot be reused. A new worker must be created if the task needs to run again.
Worker-Side Termination
A worker can also terminate itself using:
self.close();
For example:
self.onmessage = function(event) {
const result = event.data * 10;
self.postMessage(result);
self.close();
};
After sending the result, the worker terminates itself.
Handling Worker Errors
Errors can occur while executing worker code.
The main thread can listen for errors:
worker.onerror = function(error) {
console.log("Worker error:", error.message);
};
Another approach is:
worker.addEventListener("error", function(error) {
console.log("An error occurred:", error.message);
});
Error handling is important when workers perform complex operations or process external data.
Passing Objects to Workers
Workers can receive structured data.
For example:
worker.postMessage({
name: "Student",
marks: [80, 75, 90]
});
The worker can access the object:
self.onmessage = function(event) {
const student = event.data;
console.log(student.name);
console.log(student.marks);
};
This makes workers useful for processing structured application data.
Processing Large Arrays
Web Workers are particularly useful when large arrays need to be processed.
For example:
self.onmessage = function(event) {
const numbers = event.data;
const squares = numbers.map(function(number) {
return number * number;
});
self.postMessage(squares);
};
The main thread can send:
const numbers = [1, 2, 3, 4, 5];
worker.postMessage(numbers);
The worker returns:
[1, 4, 9, 16, 25]
For very large datasets, this approach can help move computational work away from the user interface thread.
Worker Lifecycle
A typical Dedicated Worker lifecycle looks like this:
Create Worker
|
v
Worker Starts
|
v
Main Thread Sends Message
|
v
Worker Processes Data
|
v
Worker Sends Result
|
v
Main Thread Receives Result
|
v
Worker Continues or Terminates
The worker can process multiple messages during its lifetime.
Multithreading in JavaScript
JavaScript's traditional execution model is single-threaded on the main page, but Web Workers allow JavaScript applications to use additional execution threads.
This is why Web Workers are often associated with multithreading.
However, it is important to understand that workers do not simply make all JavaScript code automatically multithreaded.
Instead, the developer explicitly creates workers and decides which tasks should run in them.
For example:
Main Thread
|
|-- UI operations
|-- DOM manipulation
|-- User interaction
|
+---- Worker 1
| |
| +-- Image processing
|
+---- Worker 2
|
+-- Large data calculation
This allows computational tasks to be distributed across different execution contexts.
Worker Threads Do Not Normally Share Variables
Consider a variable on the main thread:
let count = 10;
The worker cannot simply access:
console.log(count);
The worker has its own execution environment.
Instead, data should normally be passed through messages:
worker.postMessage(count);
This separation reduces many of the problems associated with multiple threads directly modifying the same variables.
Shared Memory
JavaScript also provides mechanisms for sharing certain memory between workers.
Two important technologies are:
SharedArrayBuffer
Atomics
SharedArrayBuffer allows memory to be shared between JavaScript execution contexts, while Atomics provides operations that can safely coordinate access to shared memory.
These features are more advanced and are useful for specialized high-performance applications.
Web Workers and Asynchronous Programming
Web Workers and asynchronous programming solve related but different problems.
Promises and async/await allow JavaScript to manage asynchronous operations efficiently, especially operations involving waiting, such as network requests.
Web Workers are primarily useful for moving CPU-intensive JavaScript computation away from the main thread.
For example:
Network request
|
+-- Promise / async-await
Whereas:
Heavy calculation
|
+-- Web Worker
A Web Worker can itself use asynchronous APIs as appropriate.
When Should You Use Web Workers?
Web Workers are useful when an operation requires significant CPU processing.
Common examples include:
Image Processing
Large images may require operations such as:
-
Pixel manipulation
-
Image filtering
-
Resizing
-
Compression
-
Format conversion
These tasks can sometimes be moved to a worker.
Large Data Processing
Applications working with thousands or millions of records may use workers for:
-
Sorting
-
Filtering
-
Searching
-
Data transformation
-
Statistical calculations
Complex Mathematical Calculations
Workers can handle:
-
Mathematical simulations
-
Numerical calculations
-
Scientific computations
-
Large-scale data analysis
Parsing Large Files
Large files can require significant processing before their information can be displayed.
A worker can process the data while the main thread continues responding to the user.
Cryptographic or Encoding Operations
Certain computationally intensive encoding or cryptographic operations can benefit from background execution, depending on the API and implementation.
When Should You Avoid Web Workers?
Web Workers are not necessary for every JavaScript task.
For a small calculation:
const result = 10 * 20;
Creating a worker would add unnecessary complexity.
Workers are most valuable when the computation is substantial enough that keeping it on the main thread could noticeably affect responsiveness.
You should also consider the overhead of transferring data between threads.
Advantages of Web Workers
1. Improved Responsiveness
Heavy processing can occur away from the main UI thread.
2. Better User Experience
The page can continue responding to user actions while background computation takes place.
3. Parallel Processing
Multiple workers can perform independent tasks concurrently.
4. Separation of Responsibilities
The main thread can focus on UI operations while workers focus on computation.
5. Useful for Large Data
Workers can process large datasets without forcing all processing onto the UI thread.
Limitations of Web Workers
1. No Direct DOM Access
Workers cannot directly manipulate the page's DOM.
2. Communication Has Overhead
Data generally has to be passed between the main thread and worker.
3. More Complex Architecture
Applications must manage:
-
Worker creation
-
Messages
-
Results
-
Errors
-
Worker termination
4. Not Every Task Benefits
Small operations do not need workers.
5. Debugging Can Be More Complicated
Debugging code running in different execution contexts can require additional attention.
Example: Worker-Based Number Processing
Main JavaScript
const worker = new Worker("worker.js");
const numbers = [10, 20, 30, 40, 50];
worker.postMessage(numbers);
worker.onmessage = function(event) {
console.log("Processed data:", event.data);
};
worker.onerror = function(error) {
console.error("Worker error:", error.message);
};
worker.js
self.onmessage = function(event) {
const numbers = event.data;
const result = numbers.map(function(number) {
return number * 2;
});
self.postMessage(result);
};
The main thread sends the array to the worker.
The worker processes every number.
The worker sends the result back.
The main thread receives the processed array and can then display it.
Important Security Consideration
Workers are subject to browser security rules. For example, creating a worker from a script loaded from an unrelated origin is generally restricted by the browser's same-origin and related security mechanisms.
Workers should also be created from appropriately served resources, and applications should follow secure content-loading practices.
Web Workers vs Main Thread
| Feature | Main Thread | Web Worker |
|---|---|---|
| DOM access | Yes | No direct DOM access |
| UI interaction | Yes | No |
| Heavy computation | Can block UI | Suitable for background processing |
| Separate execution context | No | Yes |
| Communication | Direct | Message-based |
document |
Available | Not normally available |
| Multiple workers | Not applicable | Possible |
| Best use | UI and application coordination | CPU-intensive background work |
Best Practices
When using Web Workers, follow these practices:
1. Use Workers for CPU-Intensive Tasks
Do not create workers for trivial operations.
2. Keep Messages Efficient
Avoid repeatedly sending unnecessarily large amounts of data.
3. Handle Errors
Always consider what should happen if worker execution fails.
4. Terminate Unnecessary Workers
Use:
worker.terminate();
when a worker is no longer needed.
5. Separate UI and Processing Logic
Keep DOM manipulation on the main thread and computational logic in the worker.
6. Consider Data Transfer Costs
Moving large amounts of data between threads can itself have a performance cost.
Conclusion
Web Workers provide JavaScript applications with a way to perform background processing without keeping all computation on the browser's main thread. A worker runs in a separate execution context and communicates with the main thread primarily through postMessage() and message events.
They are particularly valuable for computationally intensive operations such as large data processing, image processing, complex calculations, and file processing. The main thread remains responsible for the user interface and DOM, while the worker handles suitable background tasks.
The key idea is:
Main Thread
|
| User Interface
| DOM
| User Interaction
|
|---- Message ---->
|
Worker Thread
|
| Heavy Computation
| Data Processing
|
|<--- Result -------
|
Main Thread
|
| Display Result
Understanding Web Workers is an important step toward building responsive, high-performance JavaScript applications, especially when an application needs to perform substantial computation without freezing the user interface.