Moving heavy lifting into background threads is now easier with JavaScript modules in web workers.
JavaScript is single-threaded, which means it can only perform one operation at a time. This is intuitive and works well for lots of cases on the web, but can become problematic when we need to do heavy lifting tasks like data processing, parsing, computation, or analysis. As more and more complex applications are delivered on the web, there's an increased need for multi-threaded processing.
On the web platform, the main primitive for threading and parallelism is the Web Workers API. Workers are a lightweight abstraction on top of operating system threads that expose a message passing API for inter-thread communication. This can be immensely useful when performing costly computations or operating on large datasets, allowing the main thread to run smoothly while performing the expensive operations on one or more background threads.
Here's a typical example of worker usage, where a worker script listens for messages from the main thread and responds by sending back messages of its own:
page.js:
const worker = new Worker('worker.js');
worker.addEventListener('message', e => {
console.log(e.data);
});
worker.postMessage('hello');
worker.js:
addEventListener('message', e => {
if (e.data === 'hello') {
postMessage('world');
}
});
The Web Worker API has been available in most browsers for over ten years. While that means workers have excellent browser support and are well-optimized, it also means they long predate JavaScript modules. Since there was no module system when workers were designed, the API for loading code into a worker and composing scripts has remained similar to the synchronous script loading approaches common in 2009.
History: classic workers
The Worker constructor takes a classic
script URL, which is
relative to the document URL. It immediately returns a reference to the new worker instance,
which exposes a messaging interface as well as a terminate()
method that immediately stops and
destroys the worker.
const worker = new Worker('worker.js');
An importScripts()
function is available within web workers for loading additional code, but it
pauses execution of the worker in order to fetch and evaluate each script. It also executes scripts
in the global scope like a classic <script>
tag, meaning the variables in one script can be
overwritten by the variables in another.
worker.js:
importScripts('greet.js');
// ^ could block for seconds
addEventListener('message', e => {
postMessage(sayHello());
});
greet.js:
// global to the whole worker
function sayHello() {
return 'world';
}
For this reason, web workers have historically imposed an outsized effect on the architecture of an
application. Developers have had to create clever tooling and workarounds to make it possible to
use web workers without giving up modern development practices. As an example, bundlers like
webpack embed a small module loader implementation into generated code that uses importScripts()
for code loading, but wraps modules in functions to avoid variable collisions and simulate
dependency imports and exports.
Enter module workers
A new mode for web workers with the ergonomics and performance benefits of JavaScript
modules is shipping in Chrome 80, called module workers. The
Worker
constructor now accepts a new {type:"module"}
option, which changes script loading and
execution to match <script type="module">
.
const worker = new Worker('worker.js', {
type: 'module'
});
Since module workers are standard JavaScript modules, they can use import and export statements. As with all JavaScript modules, dependencies are only executed once in a given context (main thread, worker, etc.), and all future imports reference the already-executed module instance. The loading and execution of JavaScript modules is also optimized by browsers. A module's dependencies can be loaded prior to the module being executed, which allows entire module trees to be loaded in parallel. Module loading also caches parsed code, which means modules that are used on the main thread and in a worker only need to be parsed once.
Moving to JavaScript modules also enables the use of dynamic
import for lazy loading code without blocking execution of
the worker. Dynamic import is much more explicit than using importScripts()
to load dependencies,
since the imported module's exports are returned rather than relying on global variables.
worker.js:
import { sayHello } from './greet.js';
addEventListener('message', e => {
postMessage(sayHello());
});
greet.js:
import greetings from './data.js';
export function sayHello() {
return greetings.hello;
}
To ensure great performance, the old importScripts()
method is not available within module
workers. Switching workers to use JavaScript modules means all code is loaded in strict
mode. Another
notable change is that the value of this
in the top-level scope of a JavaScript module is
undefined
, whereas in classic workers the value is the worker's global scope. Fortunately, there
has always been a self
global that provides a reference to the global scope. It's available in
all types of workers including service workers, as well as in the DOM.
Preload workers with modulepreload
One substantial performance improvement that comes with module workers is the ability to preload
workers and their dependencies. With module workers, scripts are loaded and executed as standard
JavaScript modules, which means they can be preloaded and even pre-parsed using modulepreload
:
<!-- preloads worker.js and its dependencies: -->
<link rel="modulepreload" href="worker.js">
<script>
addEventListener('load', () => {
// our worker code is likely already parsed and ready to execute!
const worker = new Worker('worker.js', { type: 'module' });
});
</script>
Preloaded modules can also be used by both the main thread and module workers. This is useful for modules that are imported in both contexts, or in cases where it's not possible to know in advance whether a module will be used on the main thread or in a worker.
Previously, the options available for preloading web worker scripts were limited and not
necessarily reliable. Classic workers had their own "worker" resource type for preloading, but no
browsers implemented <link rel="preload" as="worker">
. As a result, the primary technique
available for preloading web workers was to use <link rel="prefetch">
, which relied entirely
on the HTTP cache. When used in combination with the correct caching headers, this made it possible
to avoid worker instantiation having to wait to download the worker script. However, unlike
modulepreload
this technique did not support preloading dependencies or pre-parsing.
What about shared workers?
Shared workers have
been updated with support for JavaScript modules as of Chrome 83. Like dedicated workers,
constructing a shared worker with the {type:"module"}
option now loads the worker script as a
module rather than a classic script:
const worker = new SharedWorker('/worker.js', {
type: 'module'
});
Prior to support of JavaScript modules, the SharedWorker()
constructor expected only a
URL and an optional name
argument. This will continue to work for classic shared worker usage; however
creating module shared workers requires using the new options
argument. The available
options
are the same as those for a dedicated worker, including the name
option that supersedes
the previous name
argument.
What about service worker?
The service worker specification has already been
updated to support accepting a
JavaScript module as the entry point, using the same {type:"module"}
option as module workers,
however this change has yet to be implemented in browsers. Once that happens, it will be possible
to instantiate a service worker using a JavaScript module using the following code:
navigator.serviceWorker.register('/sw.js', {
type: 'module'
});
Now that the specification has been updated, browsers are beginning to implement the new behavior. This takes time because there are some extra complications associated with bringing JavaScript modules to service worker. Service worker registration needs to compare imported scripts with their previous cached versions when determining whether to trigger an update, and this needs to be implemented for JavaScript modules when used for service workers. Also, service workers need to be able to bypass the cache for scripts in certain cases when checking for updates.
Additional resources and further reading
- Feature status, browser consensus and standardization
- Original module workers spec addition
- JavaScript modules for shared workers
- JavaScript modules for service workers: Chrome implementation status