What is a Web Worker?
A WebWorker is a JavaScript feature that allows scripts to run in the background, parallel to the main execution thread, without blocking it or affecting user interface performance. This allows complex computations or data processing to be performed without degrading the responsiveness of the web page.
Essentially, it is a JavaScript script that runs in a separate browser thread and executes code concurrently with the main thread. Web Workers also have their own context, which is separate from the window.
What is meant by “own context”? To simplify, within Web Workers, access to the window
, document
, or parent
objects is unavailable, meaning we cannot manipulate the DOM. At least not directly, but nothing stops us from manipulating the DOM indirectly, for example, by sending commands to the window thread using postMessage.
How to create and run a Web Worker?
It’s quite simple: you need to call the Worker constructor and pass a link to our JavaScript file with the code, and that’s it:
// window context app.js
const worker = new Worker('worker.js');
Let’s now figure out how to link the main browser thread with the Worker thread. They communicate with each other via messages:
// window context app.js
const worker = new Worker('worker.js');
worker.postMessage({ message: '415th, this is base, respond'});
Next, in the Web Worker, using the onmessage
property located in the Worker’s global context, or in other words, self
, we assign our message handler from the higher context. Note that postMessage
or self.postMessage
can be called from anywhere within our Worker.
// worker context worker.js
onmessage = function (e) {
if (e.message === "415th, this is base, respond") {
postMessage("Base, this is 415th, do you copy?");
}
};
Now, let’s receive this message in our app.js
: for this, we will also use onmessage
, but this time on our Worker object.
// window context app.js
const worker = new Worker("worker.js");
worker.postMessage({ message: "415th, this is base, respond" });
worker.onmessage = function (e) {
console.log(e); // Base, this is 415th, do you copy?
};
The attentive reader will notice that in the Worker, I sent an object with a message
property, but the response from the Worker was a simple string. This is because we can pass any object or data type in postMessage
.
Now, let’s dive deeper into Web Workers.
Earlier, I mentioned that the window
object is not available, but what exactly is available inside a Web Worker? In fact, there’s a lot available, but I’ll only list some of the Browser API functions we use most often — these include fetch
, setInterval
, setTimeout
, requestAnimationFrame
, queueMicrotask
. A complete list of supported APIs can be found here.
Previously, we discussed the Dedicated Worker, which is a Worker that is only accessible in the script that created it. If a Worker has its state, that state is only accessible in the script that created the Worker. But what if we want our Worker instance to be accessible from different tabs of the application and from different places within the application? This capability is provided by Shared Workers.
What are Shared Workers?
In web development, Shared Workers are a type of Web Worker that allows creating a multi-threaded, parallel execution stream shared between multiple tabs, iframes, or windows within the same origin. This means that a Shared Worker can be used simultaneously by multiple parts of a web application to exchange data, synchronize states, or perform background tasks without the need to reload or duplicate in each tab or window. It’s worth noting that the state of a Shared Worker remains alive as long as someone remembers it.
Let’s now figure out how to create and launch a Shared Worker.
Overall, it’s the same as with a Dedicated Worker, but with some exceptions. First, to create a SharedWorker instance, you need to use the SharedWorker object/class. Second, working with onmessage
and postMessage
happens through the port
property:
// app1.js
const sharedWorker = new SharedWorker("worker.js");
sharedWorker.port.onmessage = (event) => {
console.log("data from worker", event);
};
const sendDataToWorker = () => {
sharedWorker.port.postMessage(1);
};
After that we are doing the same in the other script:
// app2.js
const sharedWorker = new SharedWorker("worker.js");
sharedWorker.port.onmessage = (event) => {
console.log("data from worker", event);
};
const sendDataToWorker = () => {
sharedWorker.port.postMessage(2);
};
Let’s now look at the structure of our SharedWorker:
// worker.js
let sum = 0;
onconnect = (connect) => {
// there will always be one port in ports
port = connect.ports[0];
port.onmessage = (event) => {
sum += event;
};
port.postMessage(sum);
};
Inside onconnect
, we receive an event, which in our case we call connect
, and from there we extract the port
object. It’s always the only one in this array, and it’s not entirely clear why it was designed as an array, possibly as a provision for the future. Next, we take the port
and subscribe to incoming messages on it. Sending messages follows the same algorithm and is carried out through the port
object.
As you may have noticed, ‘ports’ is an array, but it always contains only one element, one port. Probably this is a provision for the future.
Nesting of Web Workers
Workers can be nested, and Workers can manage other Workers. The creation principle is identical to the standard method for creating a Worker.
Imports in Web Workers
Starting with browser versions released in June 2023, nearly all browsers support ES modules in the context of Workers, allowing you to use imports with the ‘import xxxxx from ‘lib’’ syntax. This information will be useful when configuring application builds.
Sending Data to a Web Worker
Sending data to and from a Web Worker is usually done through the messaging mechanism, using the postMessage()
method. When you send data to a Web Worker (or back to the main thread from the Worker), the data is copied by default, which can lead to additional performance costs, especially when working with large volumes of data.
Sending Data Without Copying
To optimize performance and minimize the costs of copying data, you can use the technique of transferring data through Transferable objects. Transferable objects allow data to be exchanged between the main thread and a Web Worker (or between Web Workers) without copying, by transferring ownership of the data from one context to another. This means that the source loses access to the data after it’s sent. Examples of transferable objects include ArrayBuffer
and MessagePort
.
Example of Using Transferable Objects
Sending data to a Web Worker:
// Creating ArrayBuffer
var buffer = new ArrayBuffer(1024); // 1024 bytes
// Sending ArrayBuffer в Worker
worker.postMessage(buffer, [buffer]);
Receiving data in a Web Worker:
onmessage = function(e) {
// Receiving ArrayBuffer
var buffer = e.data;
// Ready to work with the data
};
In this example, the ArrayBuffer is sent to the Web Worker using postMessage
, and the ArrayBuffer itself is specified in the second argument of postMessage
as a transferable object. This means that after the ArrayBuffer is sent from the main thread, it is no longer available in that thread, and ownership is transferred to the Web Worker.
Limitations
After transferring a transferable object, the source loses access to the object. This means that the object cannot be used in the source after it has been sent.
Not all objects can be transferred as ‘transferable’. The types of objects that can be transferred in this way are limited and include ArrayBuffer
and MessagePort
.
Advantages
Using transferable objects to transfer data between the main thread and Web Workers significantly improves performance when working with large volumes of data, as they avoid the costly process of copying data. This is especially important for applications requiring high performance, such as games, graphic editors, and applications for processing video and audio in real time.
Conclusion
Web Workers are a powerful tool for developing more responsive and efficient web applications. They allow offloading heavy computations and background tasks from the main thread, thereby providing a better user experience. However, it’s necessary to consider their limitations and implementation specifics for effective use in your projects.