Real-time processing with Web Workers
Brian Neville-O'Neill
Posted on July 8, 2019
As a JavaScript developer, you should already know its single-threaded processing model: all of your JavaScript code is executed within a single thread. Even event handling and asynchronous callbacks are executed within the same thread and multiple events are processed sequentially, one after the other. In other words, there’s no parallelism in the execution of ordinary JavaScript code.
It may sound strange because that means JavaScript code is not fully exploiting your machine’s computing power. In addition, this model may cause some issues when a chunk of code takes too long to run. In this case, your application may become unresponsive.
Fortunately, recent web browsers provide a way to overcome this potential performance issue. The HTML5 specification introduces Web Workers to provide parallelism in JavaScript computing on the browser side.
In this article, we are going to illustrate how to use Web Workers. We will build a simple text analyzer and progressively enhance its implementation in order to avoid performance issues due to the JavaScript single-threaded processing model.
Building a real-time text analyzer
Our goal is to implement a simple application showing some statistical data about a text as the user is typing it in a text area.
The HTML markup of the application looks something like this :
<textarea id="text" rows="10" cols="150" placeholder="Start writing...">
</textarea>
<div>
<p>Word count: <span id="wordCount">0</span></p>
<p>Character count: <span id="charCount">0</span></p>
<p>Line count: <span id="lineCount">0</span></p>
<p>Most repeated word: <span id="mostRepeatedWord"></span> (<span id="mostRepeatedWordCount">0</span> occurrences)</p>
</div>
You can see a textarea element, where the user can write their text, and a div element, where the application shows statistical data about the inserted text, such as word count, characters, lines, and the most repeated word. Remember that this data is shown in real time, while the user is writing.
The relevant JavaScript code extracting and displaying the statistical data is shown below:
const text = document.getElementById("text");
const wordCount = document.getElementById("wordCount");
const charCount = document.getElementById("charCount");
const lineCount = document.getElementById("lineCount");
const mostRepeatedWord = document.getElementById("mostRepeatedWord");
const mostRepeatedWordCount = document.getElementById("mostRepeatedWordCount");
text.addEventListener("keyup", ()=> {
const currentText = text.value;
wordCount.innerText = countWords(currentText);
charCount.innerText = countChars(currentText);
lineCount.innerText = countLines(currentText);
let mostRepeatedWordInfo = findMostRepeatedWord(currentText);
mostRepeatedWord.innerText = mostRepeatedWordInfo.mostRepeatedWord;
mostRepeatedWordCount.innerText = mostRepeatedWordInfo.mostRepeatedWordCount;
});
Here you can see a block of statements getting the various DOM elements involved in displaying data and an event listener catching this data when the user finishes pressing each key.
Inside the body of the keyup event listener you find a few calls to the functions performing the actual data analysis: countWords(), countChars(), countLines() and findMostRepeatedWord(). You can find the implementation of these functions and the whole implementation of the text analyzer on CodePen.
Performance issues with the single thread
By analyzing the source code of this simple text analyzer application, you can see that the statistical extraction is performed each time the user finishes pressing a key on their keyboard. Of course, the computing effort related to the data extraction depends on the length of the text, so you might have a loss of performance while the text size grows.
Consider that the text analysis functions taken into account by this example are very easy, but you might want to extract more complex data such as keywords and their relevance, word classification, sentence length average and so on. Even if with a short or medium-length text this application might perform well, you could experience a loss of performance and getting the application to become unresponsive with a long text, especially when it is executed in a low-performance device, such as a smartphone.
Web Workers basics
The single-threaded processing model is intrinsic in the JavaScript language specification and it is applied both on the browser and on the server. To overcome this language restriction, the HTML5 specifications introduced the worker concept, that is an object providing a way to execute JavaScript code in a separate thread.
Creating a worker is straightforward: all you need is to isolate the code you want to execute in a separate thread in a file and create a worker object by invoking the _Worker()_constructor, as shown by the following example:
const myWorker = new Worker(“myWorkerCode.js”);
This type of worker is known as a Web Worker (another type of worker is the Service worker, but it is out of the scope of this article).
The interaction between the main thread and the worker’s thread is based on a message exchange system. Both the main thread and the worker’s thread can send messages by using the postMessage() method and receive messages by handling the message event.
For example, the main thread may start the worker’s thread by sending a message like this :
myWorker.postMessage(“start”);
As you can see, we passed the_start>string as an argument for _postMessage(), but you can pass whatever you want. It depends on you and on what your Web Worker expects but, remember, you cannot pass functions. Keep in mind, however, that data is passed by value. So, if you pass an object it will be cloned and any changes the worker makes on it will not affect the original object.
The worker receives the message by implementing a listener for the message event, as shown below:
self.addEventListener(“message”, (event) => {
if (event.data === “start”) {
//do things
}
});
You can notice the self keyword. It refers to the current worker context, that is different from the global context of the main thread. You can also use the this keyword to refer to the worker context but, by convention, self is generally preferred.
So, in the example above, you attach the event listener to the current worker context and access the data coming from the main thread through the event.data property.
In the same way, the worker can send messages to the main thread by using postMessage():
self.postMessage(“ok”);
and the main thread receives messages by handling the message event, like this:
myWorker.addEventListener(“message”, (event) => {
if (event.data === “ok”) {
//do things
}
});
Note that a worker may create another worker and communicate with it, so the interaction is not restricted to a worker and the main thread.
Finally, you can explicitly stop a worker in two ways: from inside the worker itself by invoking self.close() and from the calling thread by using the terminate() method, like in the following example:
myWorker.terminate();
A Web Worker for the text analyzer
After exploring the basics of Web Workers, let’s apply them to our application.
First, let’s extract the code to put in a separate file named textAnalyzer.js. You can take the opportunity to refactor the code by defining a function analyze() and returning the result of the text analysis, as shown here:
function analyze(str) {
const mostRepeatedWordInfo = findMostRepeatedWord(str);
return {
wordCount: countWords(str),
charCount: countChars(str),
lineCount: countLines(str),
mostRepeatedWord: mostRepeatedWordInfo.mostRepeatedWord,
mostRepeatedWordCount: mostRepeatedWordInfo.mostRepeatedWordCount
};
}
The other functions, countWords(), countChars() and so on, are defined in the same textAnalyzer.js file.
In the same file, we need to handle the message event in order to interact with the main thread. The following is the needed code:
self.addEventListener("message", (event) => {
postMessage(analyze(event.data));
});
The event listener expects the text to be analyzed in the data property of the event object. Its only task is to simply return via postMessage() the result of applying the analyze() function to the text.
Now, the JavaScript code in the main script becomes as follows:
const text = document.getElementById("text");
const wordCount = document.getElementById("wordCount");
const charCount = document.getElementById("charCount");
const lineCount = document.getElementById("lineCount");
const mostRepeatedWord = document.getElementById("mostRepeatedWord");
const mostRepeatedWordCount = document.getElementById("mostRepeatedWordCount");
const textAnalyzer = new Worker("textAnalyzer.js");
text.addEventListener("keyup", ()=> {
textAnalyzer.postMessage(text.value);
});
textAnalyzer.addEventListener("message", (event) => {
const textData = event.data;
wordCount.innerText = textData.wordCount;
charCount.innerText = textData.charCount;
lineCount.innerText = textData.lineCount;
mostRepeatedWord.innerText = textData.mostRepeatedWord;
mostRepeatedWordCount.innerText = textData.mostRepeatedWordCount;
});
As you can see, we created the textAnalyzer Web Worker based on the textAnalyzer.js file.
Each time the user enters a key, a message is sent to the worker via postMessage() with the full text. The response from the worker comes from event.data in the form of an object, whose property values are assigned to the respective DOM elements for displaying.
Since the Web Worker’s code is executed in a separate thread, the user can continue inserting new text while the text analysis is in progress, without experiencing unresponsiveness.
Handling errors
What happens if an error occurs during the worker execution? In this case, an error event is fired and you should handle it in the calling thread through a normal event listener.
Suppose, for example, that our text analyzer worker checks if the data passed in the message is actually a text, like in the following code:
self.addEventListener("message", (event) => {
if (typeof event.data === "string") {
postMessage(analyze(event.data));
} else {
throw new Error("Unable to analyze non-string data");
}
});
The listener ensures that the passed data is a string before analyzing it and sending the message to the main thread. If the passed data is not a text, an exception is thrown.
On the main thread side, you should handle this exception by implementing a listener for the error event, as shown below:
textAnalyzer.addEventListener("error", (error) => {
console.log(`Error "${error.message}" occurred in the file ${error.filename} at line ${error.lineno}`);
});
The event handler receives an error object with a few data about what went wrong. In the example we used:
- the message property describes the error that occurred,
- the filename property reports the name of the script file implementing the worker
- the lineno property contains the line number where the error occurred
You can find the complete code of this implementation by following this link.
Web Workers restrictions
I hope you agree that Web Workers are amazing and very simple to use: you just need to use plain JavaScript and standard event handling for interoperation between the threads. Nothing particularly strange or complicated.
However, keep in mind that Web Workers have a few restrictions:
- They cannot access the DOM either the window or the document objects. So, for example, don’t try to use console.log() to print messages on the browser’s console. This limitation along with passing serialized message data is necessary to make Web Workers thread-safe. It may seem too restrictive at first glance but, actually, this limitation guides you into a better separation of concerns and once you’ve learned how to deal with workers, the benefits will be clear.
- In addition, Web Workers run only if the application’s files are served via HTTP or HTTPS protocol. In other words, they don’t run if your page is loaded from your local file system via file:// protocol.
- Finally, the same origin policy also applies to Web Workers. This means that the script implementing the worker must be served from the same domain, including protocol and port, as the calling script.
Shared worker
As said before, Web Workers are used to implement expensive processing tasks in order to distribute the computational load. Sometimes the Web Worker may require a significant amount of resources, such as memory or local storage. When multiple pages or frames from the same application are opened, these resources are duplicated for each instance of the Web Worker. If the logic of your worker allows it, you could avoid growing resource requests by sharing the Web worker among multiple browser contexts.
Shared workers can help you. They are a variant of Web Workers we’ve seen so far. In order to distinguish this variant type from the previous ones, the latter are often called Dedicated workers.
Let’s take a look at how you can create a Shared worker by transforming our text analyzer.
The first step is to use the SharedWorker() constructor instead of Worker():
const textAnalyzer = new SharedWorker("textAnalyzer.js");
This constructor creates a proxy for the worker. Since the worker will communicate with multiple callers, the proxy will have a dedicated port that must be used to attach listeners and to send messages. So, you need to attach the listener for the message event as follows:
textAnalyzer.port.addEventListener("message", (event) => {
const textData = event.data;
wordCount.innerText = textData.wordCount;
charCount.innerText = textData.charCount;
lineCount.innerText = textData.lineCount;
mostRepeatedWord.innerText = textData.mostRepeatedWord;
mostRepeatedWordCount.innerText = textData.mostRepeatedWordCount;
});
Notice that the only difference is the use of the port property for attaching the event listener. In the same way, you need to use the port property to send a message via postMessage():
text.addEventListener("keyup", ()=> {
textAnalyzer.port.postMessage(text.value);
});
Unlike before, however, you need to explicitly connect your thread to the worker thread by calling the start() method, as shown below:
textAnalyzer.port.start();
This is required to make sure that ports don’t dispatch events until the listener has been added. Keep in mind, however, that you don’t need to invoke start() if you attach your listener to the onmessage property instead of using addEventListener(), like this:
textAnalyzer.port.onmessage = (event) => {
const textData = event.data;
wordCount.innerText = textData.wordCount;
charCount.innerText = textData.charCount;
lineCount.innerText = textData.lineCount;
mostRepeatedWord.innerText = textData.mostRepeatedWord;
mostRepeatedWordCount.innerText = textData.mostRepeatedWordCount;
};
On the worker side, you need to arrange a bit the worker set up by replacing the message event listener with the following code:
self.addEventListener("connect", (event) => {
const port = event.ports[0];
port.addEventListener("message", (event) => {
if (typeof event.data === "string") {
port.postMessage(analyze(event.data));
} else {
throw new Error("Unable to analyze non-string data");
}
});
port.start();
});
You added a listener for the connect event. This event fires when a caller invokes the start() method of the worker proxy’s port or when it attaches an event listener to the onmessage property. In both cases, a port is assigned to the worker and you can get it by accessing the first element of the ports array of the event object. Similar to the caller, you need to use this port to attach event listeners and send messages. In addition, if you used addEventListener() to attach your listener, you need to establish a connection with the caller through the port.start() method.
Now your worker has become a shared worker.
The full code for this implementation is available at this link.
Conclusion
In this article, we discussed the limitations that the JavaScript single-threaded processing model may have in some scenarios. The implementation of a simple real-time text analyzer tried to better explain the issue.
Web Workers were introduced to solve the potential performance issues. They were used to spawn in a separate thread. We discussed the Web Workers restrictions and finally explained how to create shared workers when we need to share a Web Worker among multiple pages or frames.
You can find the final code of the workers created in this article in this GitHub repository.
Plug: LogRocket, a DVR for web apps
LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.
Try it for free.
The post Real-time processing with Web Workers appeared first on LogRocket Blog.
Posted on July 8, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.