The statement “JavaScript is single-threaded” is practically a mantra for the frontend development community. This assertion, although it makes some simplifying assumptions, effectively describes how the JavaScript environment generally behaves inside a browser. Therefore, it is useful as a pedagogical tool for helping web developers understand JavaScript.
This single-threaded paradigm is inherently restrictive as it prevents programming patterns that are otherwise feasible in languages capable of delegating work to separate threads or processes. JavaScript is bound to this single-threaded paradigm to preserve compatibility with the various browser APIs that it must interact with. Constructs such as the Document Object Model would encounter problems if subjected to concurrent mutations via multiple JavaScript threads. Therefore, traditional concurrency constructs such as POSIX threads or Java's Thread
class are non-starters for augmenting JavaScript.
Therein lies the core value proposition of workers: Allow the primary execution thread to delegate work to a separate entity without changing the existing single-threaded model. Although the various worker types covered in this chapter all have different forms and functions, they are unified in their separation from the primary JavaScript environment.
A single JavaScript environment is essentially a virtualized environment running inside the host operating system. Each open page in a browser is allocated its own environment. This provides each page its own memory, event loop, DOM, and so on. Each page is more or less sandboxed and cannot interfere with other pages. It is trivial for a browser to manage many environments at once—all of which are executing in parallel.
Using workers, browsers are able to allocate a second child environment that is totally separated from the original page environment. This child environment is prevented from interacting with single-thread–dependent constructs such as the DOM, but is otherwise free to execute code in parallel with the parent environment.
Introductory resources will commonly draw a comparison between workers and execution threads. In many ways, this is an apt comparison, as workers and threads do indeed share many characteristics:
WorkerThread
that corresponds to an underlying platform thread.SharedArrayBuffer
to share memory between multiple environments. Whereas threads will use locks to implement concurrency control, JavaScript uses the Atomics
interface to implement concurrency control.Workers and threads have a great deal of overlap, yet there are some important differences between them:
SharedArrayBuffer
, moving data in and out of workers requires it to be copied or transferred.In both form and function, workers are not a drop-in replacement for threads. The HTML Web Worker specification says the following:
Workers are relatively heavy-weight, and are not intended to be used in large numbers. For example, it would be inappropriate to launch one worker for each pixel of a four megapixel image. Generally, workers are expected to be long-lived, have a high start-up performance cost, and a high per-instance memory cost.
There are three primary types of workers defined in the Web Worker specification: the dedicated web worker, the shared web worker, and the service worker. These are all widely available in modern web browsers.
The dedicated web worker, usually just referred to as a dedicated worker, web worker, or just worker, is the bread-and-butter utility that allows scripts to spawn a separate JavaScript thread and delegate tasks to it. A dedicated worker, as the name suggests, can only be accessed by the page that spawned it.
A shared web worker behaves much like a dedicated worker. The primary difference is that a shared worker can be accessed across multiple contexts, including different pages. Any scripts executing on the same origin as the script that originally spawned the shared worker can send and receive messages to a shared worker.
A service worker is wholly different from that of a dedicated or shared worker. Its primary purpose is to act as a network request arbiter capable of intercepting, redirecting, and modifying requests dispatched by the page.
On a web page, the window
object exposes a broad suite of global variables to scripts running inside it. Inside a worker, the concept of a "window" does not make sense, and therefore its global object is an instance of WorkerGlobalScope
. Instead of window
, this global object is accessible via the self
keyword.
The properties available on self
are a strict subset of those available on window
. Some properties return a worker-flavored version of the object:
navigator
—Returns the WorkerNavigator
associated with this worker.self
—Returns the WorkerGlobalScope
object.location
—Returns the WorkerLocation
associated with this worker.performance
—Returns a Performance
object (with a reduced set of properties and methods).console
—Returns the Console
object associated with this worker. No restriction on API.caches
—Returns the CacheStorage
object associated with this worker. No restriction on API.indexedDB
—Returns an IDBFactory
object.isSecureContext
—Returns a Boolean indicating if the context of the worker is secure.origin
—Returns the origin of the WorkerGlobalScope
object.Similarly, some methods available on self
are a subset of those available on window
. The methods on self
operate identically to their counterparts on window
:
atob()
btoa()
clearInterval()
clearTimeout()
createImageBitmap()
fetch()
setInterval()
setTimeout()
The WorkerGlobalScope
also introduces a new global method importScripts()
, which is only available inside a worker. This method is described later in the chapter.
The WorkerGlobalScope
is not actually implemented anywhere. Each type of worker uses its own flavor of global object, which inherits from WorkerGlobalScope
.
DedicatedWorkerGlobalScope
.SharedWorkerGlobalScope
.ServiceWorkerGlobalScope
.The differences between these global objects are discussed in their respective sections in this chapter.
A dedicated worker is the simplest type of web worker. Dedicated workers are created by a web page to execute scripts outside the page's thread of execution. These workers are capable of exchanging information with the parent page, sending network requests, performing file I/O, executing intense computation, processing data in bulk, or any other number of computational tasks that are unsuited for the page execution thread (where they would introduce latency issues).
Dedicated workers can be aptly described as background scripts. The characteristics of a JavaScript worker—including lifecycle management, code path, and input/output—are governed by a singular script provided when the worker is initialized. This script may in turn request additional scripts, but a worker always begins with a single script source.
The most common way to create a dedicated worker is through a loaded JavaScript file. The file path is provided to the Worker
constructor, which in turn asynchronously loads the script in the background and instantiates the worker. The constructor requires a path to a file, although that path can take several different forms.
The following simple example creates an empty dedicated worker:
EMPTYWORKER.JS // empty JS worker file
MAIN.JSconsole.log(location.href); // "https://example.com/"
const worker = new Worker(location.href + 'emptyWorker.js');
console.log(worker); // Worker {}
This demonstration is trivial, but it involves several foundational concepts:
emptyWorker.js
file is loaded from an absolute path. Depending on the structure of your application, using an absolute URL will often be redundant.main.js
.main.js
must use a Worker
object as a proxy to communicate with that worker. In the above example, this object is assigned to the worker
variable.Worker
object is available immediately inside the original environment.The previous example could be altered to use a relative path; however, this requires that main.js
is executing on the same path that emptyWorker.js
can be loaded from:
const worker = new Worker('./emptyWorker.js');
console.log(worker); // Worker {}
Worker script files can only be loaded from the same origin as the parent page. Attempts to load a worker script file from a remote origin will throw an error when attempting to construct the worker, as shown here:
// Attempt to build worker from script at https://example.com/worker.js
const sameOriginWorker = new Worker('./worker.js');
// Attempt to build worker from script at https://untrusted.com/worker.js
const remoteOriginWorker = new Worker('https://untrusted.com/worker.js');
// Error: Uncaught DOMException: Failed to construct 'Worker':
// Script at https://untrusted.com/main.js cannot be accessed
// from origin https://example.com
Workers created from a loaded script are not subject to the document's content security policy because workers execute in a separate context from the parent document. However, if the worker is loaded from a script with a globally unique identifier—as is the case when a worker is loaded from a blob—it will be subject to the content security policy of the parent document.
The Worker
object returned from the Worker()
constructor is used as the single point of communication with the newly created dedicated worker. It can be used to transmit information between the worker and the parent context, as well as catch events emitted from the dedicated worker.
The Worker
object supports the following event handler properties:
onerror
—Can be assigned an event handler that will be called whenever an ErrorEvent
of type error
bubbles from the worker.worker.addEventListener('error', handler)
.onmessage
—Can be assigned an event handler that will be called whenever a MessageEvent
of type message
bubbles from the worker.worker.addEventListener('message', handler)
.onmessageerror
—Can be assigned an event handler that will be called whenever a MessageEvent
of type messageerror
event bubbles from the worker.worker.addEventListener('messageerror', handler)
.The Worker
object also supports the following methods:
postMessage()
—Used to send information to the worker via asynchronous message events.terminate()
—Used to immediately terminate the worker. No opportunity for cleanup is afforded to the worker, and the script is abruptly ended.Inside the dedicated worker, the global scope is an instance of DedicatedWorkerGlobalScope
. This inherits from WorkerGlobalScope
and therefore includes all its properties and methods. A worker is able to access this global scope via self
:
GLOBALSCOPEWORKER.JSconsole.log('inside worker:', self);
MAIN.JSconst worker = new Worker('./globalScopeWorker.js');
console.log('created worker:', worker);
// created worker: Worker {}
// inside worker: DedicatedWorkerGlobalScope {}
As shown here, the console
object in both the top-level script and the worker will write to the browser console, which is useful for debugging. Because the web worker has a non-negligible startup latency, the worker's log message prints after the main thread's log message even though the Worker
object exists.
DedicatedWorkerGlobalScope
extends the WorkerGlobalScope
with the following properties and methods:
name
—An optional string identifier that can be provided to the Worker
constructor.postMessage()
—The counterpart to worker.postMessage()
. It is used to send messages back out of the worker to the parent context.close()
—The counterpart to worker.terminate()
. It is used to immediately terminate the worker. No opportunity for cleanup is afforded to the worker; the script is abruptly ended.importScripts()
—Used to import an arbitrary number of scripts into the worker.You will notice that the dedicated worker object and the DedicatedWorkerGlobalScope
object share some pieces of interface handlers and methods with MessagePort
: onmessage
, onmessageerror
, close()
, and postMessage()
. This is no accident: dedicated workers implicitly use a MessagePort
to communicate between contexts.
The implementation is such that the object in the parent context and the DedicatedWorkerGlobalScope
effectively absorb the MessagePort
and expose its handlers and methods as part of their own interfaces. In other words, you are still sending messages via a MessagePort
; you just aren't given access to the port itself.
There are some inconsistencies, such as the start()
and close()
conventions. Dedicated worker ports will automatically start the sending of queued messages so no start()
is required. Furthermore, the close()
method doesn't make sense in the context of a dedicated worker as closing the port would effectively orphan the worker. Therefore, a close()
called from inside the worker (or terminate()
from outside) will not just close the port but shut down the worker as well.
The Worker()
constructor is the beginning of life for a dedicated worker. Once called, it initiates a request for the worker script and returns a Worker
object to the parent context. Although the Worker
object is immediately available for use in the parent context, the associated worker may not yet have been created due to the worker script's network latency or initialization latency.
Generally speaking, dedicated workers can be informally characterized as existing in one of three states: initializing, active, and terminated. The state of a dedicated worker is opaque to any other contexts. Although a Worker
object may exist in the parent context, it cannot be ascertained if a dedicated worker is initializing, active, or terminated. In other words, a Worker
object associated with an active dedicated worker is indistinguishable from a Worker
object associated with a terminated dedicated worker.
While initializing, although the worker script has yet to begin execution, it is possible to enqueue messages for the worker. These messages will wait for the worker to become active and subsequently be added to its message queue. This behavior is demonstrated here:
INITIALIZINGWORKER.JS
self.addEventListener('message', ({data}) => console.log(data));
MAIN.JS
const worker = new Worker('./initializingWorker.js');
// Worker may still be initializing,
// yet postMessage data is handled correctly.
worker.postMessage('foo');
worker.postMessage('bar');
worker.postMessage('baz');
// foo
// bar
// baz
Once created, a dedicated worker will last the lifetime of the page unless explicitly terminated either via self-termination (self.close()
) or external termination (worker.terminate()
). Even when the worker script has run to completion, the worker environment will persist. As long as the worker is still alive, the Worker
object associated with it will not be garbage collected.
Self-termination and external termination ultimately perform the same worker termination routine. Consider the following example, where a worker self-terminates in between dispatching messages:
CLOSEWORKER.JS
self.postMessage('foo');
self.close();
self.postMessage('bar');
setTimeout(() => self.postMessage('baz'), 0);
MAIN.JS
const worker = new Worker('./worker.js');
worker.onmessage = ({data}) => console.log(data);
// foo
// bar
Although close()
is invoked, clearly execution of the worker is not immediately terminated. close()
only instructs the worker to discard all tasks in the event loop and prevent further tasks from being added. This is why "baz" never prints. It does not demand that synchronous execution halt, and therefore "bar" is still printed, as it is handled in the parent context's event loop.
Now, consider the following external termination example:
TERMINATEWORKER.JSself.onmessage = ({data}) => console.log(data);
MAIN.JSconst worker = new Worker('./worker.js');
// Allow 1000ms for worker to initialize
setTimeout(() => {
worker.postMessage('foo');
worker.terminate();
worker.postMessage('bar');
setTimeout(() => worker.postMessage('baz'), 0);
}, 1000);
// foo
Here, the worker is first sent a postMessage
with "foo," which it is able to handle prior to the external termination. Once terminate()
is invoked, the worker's message queue is drained and locked—this is why only "foo" is printed.
Over its lifecycle, a dedicated worker is associated with exactly one web page (referred to in the Web Worker specification as a document). Unless explicitly terminated, a dedicated worker will persist as long as the associated document exists. If the browser leaves the page (perhaps via navigation or closing a tab or window), the browser will flag workers associated with that document for termination, and their execution will immediately halt.
The Worker()
constructor allows for an optional configuration object as the second argument. The configuration object supports the following properties:
name
—A string identifier that can be read from inside the worker via self.name
.type
—Specifies how the loaded script should be run, either classic
or module
. classic
executes the script as a normal script; module
executes the script as a module.credentials
—When type
is set to "module," specifies how worker module scripts should be retrieved with respect to transmitting credential data. Can be omit
, same-origin
, or include
. These options operate identically to the fetch()
credentials option. When type
is set to classic
, defaults to omit
.Workers need to be created from a script file, but this does not mean that the script must be loaded from a remote resource. A dedicated worker can also be created from an inline script via a blob object URL. This allows for faster worker initialization due to the elimination of roundtrip network latency.
The following example creates a worker from an inline script:
// Create string of JavaScript code to execute
const workerScript = `
self.onmessage = ({data}) => console.log(data);
`;
// Generate a blob instance from the script string
const workerScriptBlob = new Blob([workerScript]);
// Create an object URL for the blob instance
const workerScriptBlobUrl = URL.createObjectURL(workerScriptBlob);
// Create a dedicated worker from the blob
const worker = new Worker(workerScriptBlobUrl);
worker.postMessage('blob worker script');
// blob worker script
In this example, the script string is passed into a blob instance, which is then assigned an object URL, which in turn is passed to the Worker()
constructor. The constructor happily creates the dedicated worker as normal.
Condensed, this same example could appear as follows:
const worker = new Worker(URL.createObjectURL(new Blob([`self.onmessage = ({data}) => console.log(data);`])));
worker.postMessage('blob worker script');
// blob worker script
Workers can also take advantage of function serialization with inline script initialization. Because a function's toString()
method returns the actual function code, a function can be defined in a parent context but executed in a child context. Consider the following simple example of this:
function fibonacci(n) {
return n < 1 ? 0
: n <= 2 ? 1
: fibonacci(n - 1) + fibonacci(n - 2);
}
const workerScript = `
self.postMessage(
(${fibonacci.toString()})(9)
);
`;
const worker = new Worker(URL.createObjectURL(new Blob([workerScript])));
worker.onmessage = ({data}) => console.log(data);
// 34
Here, this intentionally expensive implementation of a Fibonacci sequence is serialized and passed into a worker. It's invoked as an immediately invoked function expression (IIFE) and passed a parameter, and the result is messaged back up to the main thread. Even though the Fibonacci computation here is quite expensive, all the computation is delegated to the worker and therefore does not harm the performance of the parent context.
Worker scripts do not need to be monolithic entities. You are able to programmatically load and execute an arbitrary number of scripts with the importScripts()
method, which is available on the global worker object. This method will load scripts and synchronously execute them in order. Consider the following example, which loads and executes two scripts:
MAIN.JS
const worker = new Worker('./worker.js');
// importing scripts
// scriptA executes
// scriptB executes
// scripts imported
SCRIPTA.JS
console.log('scriptA executes');
SCRIPTB.JS
console.log('scriptB executes');
WORKER.JS
console.log('importing scripts');
importScripts('./scriptA.js');
importScripts('./scriptB.js');
console.log('scripts imported');
importScripts()
accepts an arbitrary number of script arguments. The browser may download them in any order, but the scripts will be strictly executed in parameter order. Therefore, the following worker script would be equivalent:
console.log('importing scripts');
importScripts('./scriptA.js', './scriptB.js');
console.log('scripts imported');
Script loading is subject to normal CORS restrictions, but otherwise workers are free to request scripts from other origins. This import strategy is analogous to dynamic script loading via <script>
tag generation. In that spirit, scope is shared with imported scripts. This behavior is demonstrated here:
MAIN.JS
const worker = new Worker('./worker.js', {name: 'foo'});
// importing scripts in foo with bar
// scriptA executes in foo with bar
// scriptB executes in foo with bar
// scripts imported
SCRIPTA.JS
console.log(`scriptA executes in ${self.name} with ${globalToken}`);
SCRIPTB.JS
console.log(`scriptB executes in ${self.name} with ${globalToken}`);
WORKER.JS
const globalToken = 'bar';
console.log(`importing scripts in ${self.name} with ${globalToken}`);
importScripts('./scriptA.js', './scriptB.js');
console.log('scripts imported');
You may find the need for workers to spawn their own "subworkers." This can be useful in cases where you have multiple CPU cores at your disposal for parallelization of computation. Electing to use a subworker model should be done only after a careful design consideration: running multiple web workers may incur a considerable computational overhead and should only be done if the gains from parallelization outweigh the costs.
Subworker creation works nearly identically to normal worker creation with the exception of path resolution: a subworker script path will be resolved with respect to its parent worker rather than the main page. This is demonstrated as follows (note the addition of the script directory):
MAIN.JS
const worker = new Worker('./js/worker.js');
// worker
// subworker
JS/WORKER.JS
console.log('worker');
const worker = new Worker('./subworker.js');
JS/SUBWORKER.JS
console.log('subworker');
If an error is thrown inside a worker script, the worker's sandboxing will serve to prevent it from interrupting the parent thread of execution. This is demonstrated here, where an enclosing try
/catch
block does not catch the thrown error:
MAIN.JS
try {
const worker = new Worker('./worker.js');
console.log('no error');
} catch(e) {
console.log('caught error');
}
// no error
WORKER.JS
throw Error('foo');
However, this event will still bubble up to the global worker context and can be accessed by setting an error event listener on the Worker
object. This is demonstrated here:
MAIN.JS
const worker = new Worker('./worker.js');
worker.onerror = console.log;
// ErrorEvent {message: "Uncaught Error: foo"}
WORKER.JS
throw Error('foo');
All communication to and from a worker occurs via asynchronous messages, but these messages can take on a handful of different forms.
The easiest and most common form is using postMessage()
to pass serialized messages back and forth. A simple factorial example of this is shown here:
FACTORIALWORKER.JS
function factorial(n) {
let result = 1;
while(n) { result *= n--; }
return result;
}
self.onmessage = ({data}) => {
self.postMessage(`${data}! = ${factorial(data)}`);
};
MAIN.JS
const factorialWorker = new Worker('./factorialWorker.js');
factorialWorker.onmessage = ({data}) => console.log(data);
factorialWorker.postMessage(5);
factorialWorker.postMessage(7);
factorialWorker.postMessage(10);
// 5! = 120
// 7! = 5040
// 10! = 3628800
For simple message passing, using postMessage()
to communicate between window and worker is extremely similar to message passing between two windows. The primary difference is that there is no concept of a targetOrigin
restriction, which is present for Window.prototype.postMessage
but not WorkerGlobalScope.prototype.postMessage
or Worker.prototype.postMessage
. The reason for this convention is simple: the worker script origin is restricted to the main page origin so there is no use for a filtering mechanism.
For both the main thread and the worker thread, communication via postMessage()
involves invoking a method on the global object and defining an ad-hoc transmission protocol therein. This can be replaced by the Channel Messaging API, which allows you to create an explicit communication channel between the two contexts.
A MessageChannel
instance has two ports representing the two communication endpoints. To enable a parent page and a worker to communicate over a channel, one port can be passed into the worker, as shown here:
WORKER.JS
// Store messagePort globally inside listener
let messagePort = null;
function factorial(n) {
let result = 1;
while(n) { result *= n--; }
return result;
}
// Set message handler on global object
self.onmessage = ({ports}) => {
// Only set the port a single time
if (!messagePort) {
// Initial message sends the port,
// assign to variable and unset listener
messagePort = ports[0];
self.onmessage = null;
// Set message handler on global object
messagePort.onmessage = ({data}) => {
// Subsequent messages send data
messagePort.postMessage(`${data}! = ${factorial(data)}`);
};
}
};
MAIN.JS
const channel = new MessageChannel();
const factorialWorker = new Worker('./worker.js');
// Send the MessagePort object to the worker.
// Worker is responsible for handling this correctly
factorialWorker.postMessage(null, [channel.port1]);
// Send the actual message on the channel
channel.port2.onmessage = ({data}) => console.log(data);
// Worker will respond on the channel
channel.port2.postMessage(5);
// // 5! = 120
In this example, the parent page shares a MessagePort
with a worker via postMessage
. The array notation is to pass a transferable object between contexts. This concept is covered later in the chapter. The worker maintains a reference to this port and uses it to transmit messages in lieu of transmitting them via the global object. Of course, this format still utilizes a kind of ad-hoc protocol: the worker is written to expect the first message to send the port and subsequent messages to send data.
Using a MessageChannel
instance to communicate with the parent page is largely redundant, as the global postMessage
affordance is essentially performing the same task as channel.postMessage
(not including the additional features of the MessageChannel
interface). A MessageChannel
truly becomes useful in a situation in which two workers would like to directly communicate with one another. This can be accomplished by passing one port into each worker. Consider the following example where an array is passed into a worker, passed to another worker, and passed back up to the main page:
MAIN.JS
const channel = new MessageChannel();
const workerA = new Worker('./worker.js');
const workerB = new Worker('./worker.js');
workerA.postMessage('workerA', [channel.port1]);
workerB.postMessage('workerB', [channel.port2]);
workerA.onmessage = ({data}) => console.log(data);
workerB.onmessage = ({data}) => console.log(data);
workerA.postMessage(['page']);
// ['page', 'workerA', 'workerB']
workerB.postMessage(['page'])
// ['page', 'workerB', 'workerA']
WORKER.JS
let messagePort = null;
let contextIdentifier = null;
function addContextAndSend(data, destination) {
// Add identifier to show when it reached this worker
data.push(contextIdentifier);
// Send data to next destination
destination.postMessage(data);
}
self.onmessage = ({data, ports}) => {
// If ports exist in the message,
// set up the worker
if (ports.length) {
// Record the identifier
contextIdentifier = data;
// Capture the MessagePort
messagePort = ports[0];
// Add a handler to send the received data
// back up to the parent
messagePort.onmessage = ({data}) => {
addContextAndSend(data, self);
}
} else {
addContextAndSend(data, messagePort);
}
};
In this example, each part of the array's journey will add a string to the array to mark when it arrived. The array is passed from the parent page into a worker, which adds its context identifier. It is then passed from one worker to the other, which adds a second context identifier. It is then passed back up to the main page, where the array is logged. Note in this example how, because the two workers share a common script, this array passing scheme works bidirectionally.
Scripts that run on the same origin are capable of sending and receiving conmessages on a shared BroadcastChannel
. This channel type is simpler to set up and doesn't require the messiness of port passing required with MessageChannel
. This can be accomplished as follows:
MAIN.JS
const channel = new BroadcastChannel('worker_channel');
const worker = new Worker('./worker.js');
channel.onmessage = ({data}) => {
console.log(`heard ${data} on page`);
}
setTimeout(() => channel.postMessage('foo'), 1000);
// heard foo in worker
// heard bar on page
WORKER.JS
const channel = new BroadcastChannel('worker_channel');
channel.onmessage = ({data}) => {
console.log(`heard ${data} in worker`);
channel.postMessage('bar');
}
Note here that the page waits 1,000 milliseconds before sending the initial message on the BroadcastChannel
. Because there is no concept of port ownership with this type of channel, messages broadcasted will not be handled if there is no other entity listening on the channel. In this case, without the setTimeout
, the latency of the worker initialization is sufficiently long to prevent the worker's message handler from being set before the message is actually sent.
Workers will often need to be provided with a data payload in some form. Because workers operate in a separate context, there is overhead involved in getting a piece of data from one context to another. In languages that support traditional models of multithreading, you have concepts such as locks, mutexes, and volatile variables at your disposal. In JavaScript, there are three ways of moving information between contexts: the structured clone algorithm, transferable objects, and shared array buffers.
The structured clone algorithm can be used to share a piece of data between two separate execution contexts. This algorithm is implemented by the browser behind the scenes, but it cannot be invoked explicitly.
When an object is passed to postMessage()
, the browser traverses the object and makes a copy in the destination context. The following types are fully supported by the structured clone algorithm:
Symbol
Boolean
objectString
objectBDate
RegExp
Blob
File
FileList
ArrayBuffer
ArrayBufferView
ImageData
Array
Object
Map
Set
Some things to note about the behavior of the structured clone algorithm:
Error
object, a Function
object, or a DOM node will throw an error.RegExp.prototype.lastIndex
property is not cloned.It is possible to transfer ownership from one context to another using transferable objects. This is especially useful in cases where it is impractical to copy large amounts of data between contexts. Only a handful of types are transferable:
ArrayBuffer
MessagePort
ImageBitmap
OffscreenCanvas
The second optional argument to postMessage()
is an array specifying which objects should be transferred to the destination context. When traversing the message payload object, the browser will check object references against the transfer object array and perform a transfer upon those objects instead of copying them. This means that transferred objects can be sent in a message payload, which itself is copied, such as an object or array.
The following example demonstrates a normal structured clone of an ArrayBuffer
into a worker. No object transfer occurs in this example:
MAIN.JS
const worker = new Worker('./worker.js');
// Create 32 byte buffer
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage(arrayBuffer);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
WORKER.JS
self.onmessage = ({data}) => {
console.log(`worker's buffer size: ${data.byteLength}`); // 32
};
When the ArrayBuffer
is specified as a transferable object, the reference to the buffer memory is wiped out in the parent context and allocated to the worker context. This is demonstrated here, where the memory allocated inside the ArrayBuffer
is removed from the parent context:
MAIN.JS
const worker = new Worker('./worker.js');
// Create 32 byte buffer
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage(arrayBuffer, [arrayBuffer]);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 0
WORKER.JS
self.onmessage = ({data}) => {
console.log(`worker's buffer size: ${data.byteLength}`); // 32
};
It is perfectly fine to nest transferable objects inside other object types. The encompassing object will be copied and the nested object will be transferred:
MAIN.JS
const worker = new Worker('./worker.js');
// Create 32 byte buffer
const arrayBuffer = new ArrayBuffer(32);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 32
worker.postMessage({foo: {bar: arrayBuffer}}, [arrayBuffer]);
console.log(`page's buffer size: ${arrayBuffer.byteLength}`); // 0
WORKER.JS
self.onmessage = ({data}) => {
console.log(`worker's buffer size: ${data.foo.bar.byteLength}`); // 32
};
Rather than being cloned or transferred, a SharedArrayBuffer
is an ArrayBuffer
that is shared between browser contexts. When passing a SharedArrayBuffer
inside postMessage()
, the browser will pass only a reference to the original buffer. As a result, two different JavaScript contexts will each maintain their own reference to the same block of memory. Each context is free to modify the buffer as it would with a normal ArrayBuffer
. This behavior is demonstrated here:
MAIN.JS
const worker = new Worker('./worker.js');
// Create 1 byte buffer
const sharedArrayBuffer = new SharedArrayBuffer(1);
// Create view onto 1 byte buffer
const view = new Uint8Array(sharedArrayBuffer);
// Parent context assigns value of 1
view[0] = 1;
worker.onmessage = () => {
console.log(`buffer value after worker modification: ${view[0]}`);
};
// Send reference to sharedArrayBuffer
worker.postMessage(sharedArrayBuffer);
// buffer value before worker modification: 1
// buffer value after worker modification: 2
WORKER.JS
self.onmessage = ({data}) => {
const view = new Uint8Array(data);
console.log(`buffer value before worker modification: ${view[0]}`);
// Worker assigns new value to shared buffer
view[0] += 1;
// Send back empty postMessage to signal assignment is complete
self.postMessage(null);
};
Of course, sharing the block of memory between two parallel threads introduces a risk of race conditions. In other words, the SharedArrayBuffer
instance is effectively being treated as volatile memory. This problem is demonstrated in the following example:
MAIN.JS
// Create worker pool of size 4
const workers = [];
for (let i = 0; i < 4; ++i) {
workers.push(new Worker('./worker.js'));
}
// Log the final value after the last worker completes
let responseCount = 0;
for (const worker of workers) {
worker.onmessage = () => {
if (++responseCount == workers.length) {
console.log(`Final buffer value: ${view[0]}`);
}
};
}
// Initialize the SharedArrayBuffer
const sharedArrayBuffer = new SharedArrayBuffer(4);
const view = new Uint32Array(sharedArrayBuffer);
view[0] = 1;
// Send the SharedArrayBuffer to each worker
for (const worker of workers) {
worker.postMessage(sharedArrayBuffer);
}
// (Expected result is 4000001. Actual output will be something like:)
// Final buffer value: 2145106
WORKER.JS
self.onmessage = ({data}) => {
const view = new Uint32Array(data);
// Perform 1000000 add operations
for (let i = 0; i < 1E6; ++i) {
view[0] += 1;
}
self.postMessage(null);
};
Here, each worker is executing 1,000,000 sequential operations, which read from the shared array index, perform an add, and write that value back into the array index. A race condition occurs when worker read/write operations are interleaved. For example:
To address this, the Atomics
global object allows for a worker to effectively obtain a lock on the SharedArrayBuffer
instance and perform the entire read/add/write sequence before allowing another worker to perform any operations. Incorporating Atomics.add()
into this example yields a correct final value:
MAIN.JS
// Create worker pool of size 4
const workers = [];
for (let i = 0; i < 4; ++i) {
workers.push(new Worker('./worker.js'));
}
// Log the final value after the last worker completes
let responseCount = 0;
for (const worker of workers) {
worker.onmessage = () => {
if (++responseCount == workers.length) {
console.log(`Final buffer value: ${view[0]}`);
}
};
}
// Initialize the SharedArrayBuffer
const sharedArrayBuffer = new SharedArrayBuffer(4);
const view = new Uint32Array(sharedArrayBuffer);
view[0] = 1;
// Send the SharedArrayBuffer to each worker
for (const worker of workers) {
worker.postMessage(sharedArrayBuffer);
}
// (Expected result is 4000001)
// Final buffer value: 4000001
WORKER.JS
self.onmessage = ({data}) => {
const view = new Uint32Array(data);
// Perform 1000000 add operations
for (let i = 0; i < 1E6; ++i) {
Atomics.add(view, 0, 1);
}
self.postMessage(null);
};
Because starting a worker is quite expensive, there may be situations where it is more efficient to keep a fixed number of workers alive and dispatch work to them as necessary. When a worker is performing computation, it is marked as busy and will only be ready to take on another task once it notifies the pool that it is available again. This is commonly referred to as a "thread pool" or "worker pool."
Determining the ideal number of workers in a pool is not an exact science, but the navigator.hardwareConcurrency
property will return the number of cores available on the system. Because you will likely not be able to ascertain the multithreading ability of each core, it may be best to treat this number as the upper bound for pool size.
One scenario you may encounter involves a fixed set of workers in a pool all performing the same task that is controlled by a small set of input parameters. By using a task-specific worker pool, you can allocate a fixed number of workers and feed them parameters on demand. The worker will take in these parameters, perform the long-running computation, and return the value to the pool. In turn, the pool will then send the worker additional work to perform. This example will build a relatively simplistic worker pool, but it will cover all the foundational requirements for this concept.
Begin by defining a TaskWorker
, which extends the Worker
class. This class has two jobs: keep track of whether or not it is busy performing work, and manage the information and events going in and out of the worker. Furthermore, tasks passed to this worker will be wrapped in a promise and will resolve/reject appropriately. The class can be defined as follows:
class TaskWorker extends Worker {
constructor(notifyAvailable, …workerArgs) {
super(…workerArgs);
// Initialize as unavailable
this.available = false;
this.resolve = null;
this.reject = null;
// Worker pool will pass a callback so that the
// worker can signal it needs another task
this.notifyAvailable = notifyAvailable;
// Worker script will send a 'ready' postmessage
// once fully initialized
this.onmessage = () => this.setAvailable();
}
// Called by the worker pool to begin a new task
dispatch({ resolve, reject, postMessageArgs }) {
this.available = false;
this.onmessage = ({ data }) => {
resolve(data);
this.setAvailable();
};
this.onerror = (e) => {
reject(e);
this.setAvailable();
};
this.postMessage(…postMessageArgs);
}
setAvailable() {
this.available = true;
this.resolve = null;
this.reject = null;
this.notifyAvailable();
}
}
Next, the WorkerPool
class definition must make use of this TaskWorker
class. It must also maintain a queue of tasks that have yet to be assigned to a worker. Two events can signal that a new task should be dispatched: a new task is added to the queue, or a worker finishes a task and should be sent another. The class can be defined as follows:
class WorkerPool {
constructor(poolSize, …workerArgs) {
this.taskQueue = [];
this.workers = [];
// Initialize the worker pool
for (let i = 0; i < poolSize; ++i) {
this.workers.push(
new TaskWorker(() => this.dispatchIfAvailable(), …workerArgs));
}
}
// Pushes a task onto the queue
enqueue(…postMessageArgs) {
return new Promise((resolve, reject) => {
this.taskQueue.push({ resolve, reject, postMessageArgs });
this.dispatchIfAvailable();
});
}
// Sends a task to the next available worker if there is one
dispatchIfAvailable() {
if (!this.taskQueue.length) {
return;
}
for (const worker of this.workers) {
if (worker.available) {
let a = this.taskQueue.shift();
worker.dispatch(a);
break;
}
}
}
// Kills all the workers
close() {
for (const worker of this.workers) {
worker.terminate();
}
}
}
With these two classes defined, it is now trivial to dispatch tasks to the worker pool and have them be executed as workers become available. In this example, suppose you wanted to sum 10 million floating point numbers. To save on transfer costs, this will utilize a SharedArrayBuffer
. The worker definition might appear as follows:
self.onmessage = ({data}) => {
let sum = 0;
let view = new Float32Array(data.arrayBuffer)
// Perform sum
for (let i = data.startIdx; i < data.endIdx; ++i) {
// No need for Atomics since only performing reads
sum += view[i];
}
// Send the result to the worker
self.postMessage(sum);
};
// Send messagemessate to TaskWorker to signal worker is
// ready to receive tasks.
self.postMessage('ready');
With all this in place, the code that utilizes the worker pools might appear as follows:
Class TaskWorker {
…
]
Class WorkerPool {
…
}
const totalFloats = 1E8;
const numTasks = 20;
const floatsPerTask = totalFloats / numTasks;
const numWorkers = 4;
// Create pool
const pool = new WorkerPool(numWorkers, './worker.js');
// Fill array of floats
let arrayBuffer = new SharedArrayBuffer(4 * totalFloats);
let view = new Float32Array(arrayBuffer);
for (let i = 0; i < totalFloats; ++i) {
view[i] = Math.random();
}
let partialSumPromises = [];
for (let i = 0; i < totalFloats; i += floatsPerTask) {
partialSumPromises.push(
pool.enqueue({
startIdx: i,
endIdx: i + floatsPerTask,
arrayBuffer: arrayBuffer
})
);
}
// Wait for all promises to complete, then sum
Promise.all(partialSumPromises)
.then((partialSums) => partialSums.reduce((x, y) => x + y))
.then(console.log);
// (In this example, sum should be roughly 1E8/2)
// 49997075.47203197
A shared web worker or shared worker behaves like a dedicated worker but is accessible across multiple trusted execution contexts. For example, two different tabs on the same origin will be able to access a single web worker. SharedWorker
and Worker
feature slightly different messaging interfaces, both externally and internally.
A shared worker is valuable in situations where the developer wishes to reduce computational overhead by allowing multiple execution contexts to share a worker. An example of this could be a single shared worker managing a websocket to send and receive messages for multiple same-origin pages. Shared workers are also useful when same-origin contexts wish to communicate via the shared worker.
Behaviorally speaking, shared workers can be considered an extension of dedicated workers. Worker creation, worker options, security restrictions, and importScripts()
all behave in the same way. As is the case with a dedicated worker, the shared worker also runs inside a separate execution context and can only communicate asynchronously with other contexts.
As with dedicated workers, the most common way of creating a shared worker is through a loaded JavaScript file. The file path is provided to the SharedWorker
constructor, which in turn asynchronously loads the script in the background and instantiates the worker.
The following simple example creates an empty shared worker from an absolute path:
EMPTYSHAREDWORKER.JS
// empty JS worker file
MAIN.JS
console.log(location.href); // "https://example.com/"
const sharedWorker = new SharedWorker(
location.href + 'emptySharedWorker.js');
console.log(sharedWorker); // SharedWorker {}
The previous example could be altered to use a relative path; however, this requires that main.js
is executing on the same path that emptySharedWorker.js
can be loaded from:
const worker = new Worker('./emptyWorker.js');
console.log(worker); // Worker {}
Shared workers can also be created from an inline script, but there is little point in doing so: each blob created from an inline script string is assigned its own unique in-browser URL, and therefore shared workers created from inline scripts will always be unique. The reasons for this are covered in the next section.
An important difference between a shared worker and a dedicated worker is that, whereas the Worker()
constructor always creates a new worker instance, the SharedWorker()
constructor will only create a new worker instance if one with the same identity does not yet exist. If a shared worker matching the identity does exist, a new connection will be formed with the existing shared worker.
Shared worker identity is derived from the resolved script URL, the worker name, and the document origin. For example, the following script would instantiate a single shared worker and add two subsequent connections:
// Instantiates single shared worker
// - Constructors called all on same origin
// - All scripts resolve to same URL
// - All workers have same name
new SharedWorker('./sharedWorker.js');
new SharedWorker('./sharedWorker.js');
new SharedWorker('./sharedWorker.js');
Similarly, because all three of the following script strings resolve to the same URL, only a single shared worker is created:
// Instantiates single shared worker
// - Constructors called all on same origin
// - All scripts resolve to same URL
// - All workers have same name
new SharedWorker('./sharedWorker.js');
new SharedWorker('sharedWorker.js');
new SharedWorker('https://www.example.com/sharedWorker.js');
Because the optional worker name is part of the shared worker identity, using different worker names will coerce the browser to create multiple shared workers—one with name 'foo'
and one with name 'bar'
—even though they have the same origin and script URL:
// Instantiates two shared workers
// - Constructors called all on same origin
// - All scripts resolve to same URL
// - One shared worker has name 'foo', one has name 'bar'
new SharedWorker('./sharedWorker.js', {name: 'foo'});
new SharedWorker('./sharedWorker.js', {name: 'foo'});
new SharedWorker('./sharedWorker.js', {name: 'bar'});
As the name implies, shared workers are shared across tabs, windows, iframes, or other workers running on the same origin. Therefore, the following script run on multiple tabs will only create a worker the first time it is executed, and each successive run will connect to that same worker:
// Instantiates single shared worker
// - Constructors called all on same origin
// - All scripts resolve to same URL
// - All workers have same name
new SharedWorker('./sharedWorker.js');
The script aspect of the shared worker identity is restricted to the URL only, so the following will create two shared workers even though the same script is loaded:
// Instantiates two shared workers
// - Constructors called all on same origin
// - '?' token differentiates URLs
// - All workers have same name
new SharedWorker('./sharedWorker.js');
new SharedWorker('./sharedWorker.js?');
If this script was run in two different tabs, there would still only be two shared workers created in total. Each constructor would check for a matching shared worker and merely connect to it if it exists.
The SharedWorker
object returned from the SharedWorker()
constructor is used as the single point of communication with the newly created dedicated worker. It can be used to transmit information between the worker and the parent context via a MessagePort
, as well as catch error
events emitted from the dedicated worker.
The SharedWorker
object supports the following properties:
onerror
—Can be assigned an event handler that will be called whenever an ErrorEvent
of type error
bubbles from the worker.sharedWorker.addEventListener('error', handler)
.port
—The dedicated MessagePort
for communication with the shared worker.Inside the shared worker, the global scope is an instance of SharedWorkerGlobalScope
. This inherits from WorkerGlobalScope
and therefore includes all its properties and methods. As with dedicated workers, a shared worker is able to access this global scope via self
.
SharedWorkerGlobalScope
extends the WorkerGlobalScope
with the following properties and methods:
name
—An optional string identifier, which can be provided to the SharedWorker
constructor.importScripts()
—Used to import an arbitrary number of scripts into the worker.close()
—The counterpart to worker.terminate()
. It is used to immediately terminate the worker. No opportunity for cleanup is afforded to the worker; the script is abruptly ended.onconnect
—Should be set as the handler for when a new connection is made to the shared worker. connect
events include a ports
array of MessagePort
instances, which can be used to send messages back up to the parent context.connect
event occurs when a connection is made to the shared worker either via worker.port.onmessage
or worker.port.start()
.sharedWorker.addEventListener('connect', handler)
.A shared worker's lifecycle has the same stages and features of a dedicated worker. The difference is that, while a dedicated worker is inextricably bound to a single page, a shared worker will persist as long as a context remains connected to it.
Consider the following script, which creates a dedicated worker each time it is executed:
new Worker('./worker.js');
The following table details what happens when three tabs that spawn workers are opened and closed in sequence.
EVENT | RESULT | TOTAL WORKERS AFTER EVENT |
Tab 1 executes main.js | Dedicated worker 1 spawned | 1 |
Tab 2 executes main.js | Dedicated worker 2 spawned | 2 |
Tab 3 executes main.js | Dedicated worker 3 spawned | 3 |
Tab 1 closed | Dedicated worker 1 terminated | 2 |
Tab 2 closed | Dedicated worker 2 terminated | 1 |
Tab 3 closed | Dedicated worker 3 terminated | 0 |
As shown in the table, parity exists between the number of times the script is executed, the number of open tabs, and the number of running workers. Next, consider the following trivial script, which creates or connects to a shared worker each time it is executed:
new SharedWorker('./sharedWorker.js');
The following table details what happens when three tabs are opened and closed in sequence.
EVENT | RESULT | TOTAL WORKERS AFTER EVENT |
Tab 1 executes main.js | Shared worker 1 spawned | 1 |
Tab 2 executes main.js | Connects to shared worker 1 | 1 |
Tab 3 executes main.js | Connects to shared worker 1 | 1 |
Tab 1 closed | Disconnects from shared worker 1 | 1 |
Tab 2 closed | Disconnects from shared worker 1 | 1 |
Tab 3 closed | Disconnects from shared worker 1. No connections remain, so worker 1 is terminated. | 0 |
As shown in this table, subsequently invoking new SharedWorker()
in tab 2 and 3 will connect to the existing worker. As connections are added and removed from the worker, the total number of connections is tracked. When the number of connections goes to zero, the worker is terminated.
Importantly, there is no way to programmatically terminate a shared worker. You will notice that the terminate()
method is absent from the SharedWorker
object. Furthermore, calling close()
on a shared worker port (discussed later in the chapter) will not trigger the termination of the worker, even if there is only a single port connected to the worker.
A SharedWorker
"connection" is uncorrelated with the connected state of an associated MessagePort
or MessageChannel
. As soon as a connection to a shared worker is established, the browser is responsible for managing that connection. The established connection will persist for the lifetime of the page, and only when the page is torn down and there are no further connections to a shared worker will the browser elect to terminate the worker.
A connect
event is fired inside a shared worker each time the SharedWorker
constructor is called, whether or not a worker was created. This is demonstrated in the following example, where the constructor is called inside a loop:
SHAREDWORKER.JS
let i = 0;
self.onconnect = () => console.log(`connected ${++i} times`);
MAIN.JS
for (let i = 0; i < 5; ++i) {
new SharedWorker('./sharedWorker.js');
}
// connected 1 times
// connected 2 times
// connected 3 times
// connected 4 times
// connected 5 times
Upon a connect
event, the SharedWorker
constructor implicitly creates a MessageChannel
and passes ownership of a MessagePort
unique to that instance of SharedWorker
. This MessagePort
is available inside the connect
event object as the ports
array. Because a connect event will only ever represent a single connection, you can safely assume that the ports
array will have a length of exactly 1.
The following demonstrates accessing the event's ports
array. Here, a Set
is used to ensure that only unique object instances are tracked:
SHAREDWORKER.JS
const connectedPorts = new Set();
self.onconnect = ({ports}) => {
connectedPorts.add(ports[0]);
console.log(`${connectedPorts.size} unique connected ports`);
};
MAIN.JS
for (let i = 0; i < 5; ++i) {
new SharedWorker('./sharedWorker.js');
}
// 1 unique connected ports
// 2 unique connected ports
// 3 unique connected ports
// 4 unique connected ports
// 5 unique connected ports
Importantly, shared workers behave asymmetrically in terms of setup and teardown. Each new SharedWorker
connection triggers an event, but there is no corresponding event for when a SharedWorker
instance disconnects (such as when a page is closed).
In the previous example, as pages connect and disconnect to the same shared worker, the connectedPorts
collection will become polluted with dead ports with no way to identify them. One solution to this problem is to send an explicit teardown message right as the page is about to be destroyed at the beforeunload
event, and to allow the shared worker to clean up.
A service worker is a type of web worker that behaves like a proxy server inside the browser. Service workers allow you to intercept outgoing requests and cache the response. This allows a web page to work without network connectivity, as some or all of the page can potentially be served from the service worker cache. A service worker can also make use of the Notifications API, the Push API, the Background Sync API, and the Channel Messaging API.
Like shared workers, multiple pages on a single domain will all interact with a single service worker instance. However, to enable features such as the Push API, service workers can also survive the associated tab or browser being closed and wait for an incoming push event.
Ultimately, most developers will find that service workers are most useful for two primary tasks: acting as a caching layer for network requests, and enabling push notifications. In this sense, the service worker is a tool designed to enable web pages to behave like native applications.
As a class of web worker, a service worker exhibits many of the same rhythms as a dedicated or shared worker. It exists in a totally separate execution context and can only be interacted with via asynchronous messaging. However, there are a number of fundamental differences between service and dedicated/shared.
Service workers are different from dedicated and shared workers in that they have no global constructor. Instead, a service worker is managed through the ServiceWorkerContainer
, available via navigator.serviceWorker
. This object is the top-level interface, which allows you to direct the browser to create, update, destroy, or interact with a service worker.
console.log(navigator.serviceWorker);
// ServiceWorkerContainer { … }
Service workers are similar to shared workers in that a new one will be spawned if it does not yet exist; otherwise a connection is obtained to an existing one. Instead of creation through a global constructor, the ServiceWorkerContainer
exposes a register()
method, which is passed a script URL in the same fashion as the Worker
or SharedWorker
constructors:
EMPTYSERVICEWORKER.JS
// empty service worker script
MAIN.JS
navigator.serviceWorker.register('./emptyServiceWorker.js');
The register()
method returns a promise, which resolves to a ServiceWorkerRegistration
object or rejects if registration fails.
EMPTYSERVICEWORKER.JS
// empty service worker script
MAIN.JS
// Successfully registers a service worker, resolves
navigator.serviceWorker.register('./emptyServiceWorker.js')
.then(console.log, console.error);
// ServiceWorkerRegistration { … }
// Attempts to register service worker from nonexistent file, rejects
navigator.serviceWorker.register('./doesNotExist.js')
.then(console.log, console.error);
// TypeError: Failed to register a ServiceWorker:
// A bad HTTP response code (404) was received when fetching the script.
The nature of service workers allows you some flexibility with respect to choosing when to begin the registration. Once a service worker is activated after the initial register()
, subsequent calls to register()
on the same page and with the same URL are effectively a no-op. Furthermore, even though service workers are not globally supported by browsers, a service worker should be effectively invisible to the page because its proxy-like behavior means actions that would otherwise be handled will merely be dispatched to the network as normal.
Because of the aforementioned properties, an extremely common pattern for service worker registration is to gate it behind feature detection and the page's load
event. This frequently appears as follows:
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('./serviceWorker.js');
});
}
Without the load
event gating, the service worker's registration will overlap with loading of page resources, which may slow the overall initial page render. Unless the service worker is responsible for managing cache behavior, which must occur as early as possible in the page setup process (such as when using clients.claim()
, discussed later in the chapter), waiting for the load
event is usually a sensible choice that still allows the page to enjoy all the benefits of using service workers.
The ServiceWorkerContainer
interface is the top-level wrapper for the browser's service worker ecosystem. It provides facilities for managing service worker state and lifecycle.
The ServiceWorkerContainer
is always accessible in the client context:
console.log(navigator.serviceWorker);
// ServiceWorkerContainer { … }
A ServiceWorkerContainer
supports the following event handlers:
oncontrollerchange
—Can be assigned an event handler that will be called whenever a controllerchange
event is emitted from the ServiceWorkerContainer
.ServiceWorkerRegistration
is acquired.navigator.serviceWorker.addEventListener('controllerchange', handler)
.onerror
—Can be assigned an event handler that will be called whenever an ErrorEvent
of type error
bubbles from any associated service worker.navigator.serviceWorker.addEventListener('error', handler)
.onmessage
—Can be assigned an event handler that will be called whenever a MessageEvent
of type message
is sent from the service worker.navigator.serviceWorker.addEventListener('message', handler)
.A ServiceWorkerContainer
supports the following properties:
ready
—Returns a promise, which might resolve with an activated ServiceWorkerRegistration
object. This promise will never reject.controller
—Returns the activated ServiceWorker
object associated with the current page, or null if there is no active service worker.A ServiceWorkerContainer
supports the following methods:
register()
—Creates or updates a ServiceWorkerRegistration
using the provided url
and options
object.getRegistration()
—Returns a promise, which will resolve with a ServiceWorkerRegistration
object that matches the provided scope, or resolve with undefined if there is no matching service worker.getRegistration()
—Returns a promise, which will resolve with an array of all ServiceWorkerRegistration
objects that are associated with the ServiceWorkerContainer
, or an empty array if there are no associated service workers.startMessage()
—Starts the transmission of message dispatches via Client.postMessage()
.The ServiceWorkerRegistration
object represents a successful registration of a service worker. The object is available inside the resolved promise handler returned from register()
. This object allows you to determine the lifecycle status of the associated service worker via several properties.
The registration object is provided inside a promise after navigator.serviceWorker.register()
is called. Multiple calls on the same page with the same URL will return the same registration object.
navigator.serviceWorker.register('./serviceWorker.js')
.then((registrationA) => {
console.log(registrationA);
navigator.serviceWorker.register('./serviceWorker2.js')
.then((registrationB) => {
console.log(registrationA === registrationB);
});
});
A ServiceWorkerRegistration
supports the following event handler:
onupdatefound
can be assigned an event handler, which will be called whenever an event of type updatefound
is fired from the service worker.ServiceWorkerRegistration.installing
acquiring a new service worker.serv serviceWorkerRegistration.addEventListener('updatefound', handler)
.A ServiceWorkerRegistration
supports the following general properties:
scope
—Returns the full URL path of the service worker's scope. This value is derived from the path from which the service worker's script was retrieved and/or the scope provided inside register()
.navigationPreload
—Returns the NavigationPreloadManager
instance associated with this registration object.pushManager
—Returns the PushManager
instance associated with this registration object.A ServiceWorkerRegistration
also supports the following properties, which can be used to inspect service workers at various stages of their lifecycles:
installing
—Returns the service worker with a state of installing
if there is currently one, else null
.waiting
—Returns the service worker with a state of waiting
if there is currently one, else null
.active
—Returns the service worker with a state of activating
or active
if there is currently one, else null
.Note that these properties are a one-time snapshot of the state of a service worker. These are suitable for most use cases, as an active service worker will not change state over the lifetime of a page unless coerced to do so with something like ServiceWorkerGlobalScope.skipWaiting()
.
A ServiceWorkerRegistration
supports the following methods:
getNotifications()
—Returns a promise, which resolves with an array of Notification
objects.showNotifications()
—Displays a notification configurable with a title
and options
arguments.update()
—Re-requests the service worker script directly from the server and initiates fresh installation if the new script differs.unregister()
—Will attempt to un-register a service worker registration. This allows service worker execution to complete before performing the unregistration.The ServiceWorker
object can be obtained in one of two ways: via the controller property on the ServiceWorkerController
object, and the active property on the ServiceWorkerRegistration
object. This object inherits from the Worker
prototype, and therefore offers all its properties and methods, but notably absent is the terminate()
method.
A ServiceWorker
supports the following event handler:
onstatechange
can be assigned an event handler that will be called whenever a statechange
event is emitted from the ServiceWorker
.ServiceWorker.state
changes.serviceWorker.addEventListener('statechange', handler)
.A ServiceWorker
supports the following properties:
scriptURL
—The resolved URL used to register the service worker. For example, if the service worker was created with the relative path './serviceWorker.js'
, then if it were registered on https://www.example.com
the scriptURL
property would return https://www.example.com/serviceWorker.js
.state
—Returns a string identifying the state of the service worker. The possible states are as follows:installing
installed
activating
activated
redundant
As a class of web worker, service workers are subject to the normal restrictions with respect to origin matching of the loaded script. (See the section “Worker Security Restrictions” earlier in the chapter for details on this.) Additionally, because service workers are granted nearly unlimited power to modify and redirect network requests and loaded static resources, the service worker API is only available in secure https
contexts; in http
contexts, navigator.serviceWorker
will be undefined
. To allow for ease of development, browsers make an exemption to the secure context rule for pages loaded locally, either via localhost
or 127.0.0.1
.
Inside the service worker, the global scope is an instance of ServiceWorkerGlobalScope
. This inherits from WorkerGlobalScope
and therefore includes all its properties and methods. A service worker is able to access this global scope via self
.
ServiceWorkerGlobalScope
extends the WorkerGlobalScope
with the following properties and methods:
caches
—Returns the service worker's CacheStorage
object.clients
—Returns the service worker's Clients
interface. Used to access underlying Client
objects.registration
—Returns the service worker's ServiceWorkerRegistration
object.skipWaiting()
—Forces the service worker into an active state. This is used in conjunction with Clients.claim()
.fetch()
—Performs a normal fetch from inside the service worker. This is used when the service worker determines that an actual outgoing network request should be made (instead of returning a cached value).Whereas dedicated or shared workers have only a message
event as an input, service workers are able to consume a large number of events, which are triggered by actions on the page, notification actions, or push events.
A service worker's global scope can listen for the following events, broken down here by category:
Service worker state
install
is fired when the service worker enters the installing state (visible in the client via ServiceWorkerRegistration.installing
). You can also set a handler for this event on self.oninstall
.activate
is fired when the service worker enters the activating or activated state (visible in the client via ServiceWorkerRegistration.active
). You can also set a handler for this event on self.onactivate
.Fetch API
fetch
is fired when the service worker intercepts a fetch()
called in the main page. The service worker's fetch
event handler has access to the FetchEvent
and can adjust the outcome as it sees fit. You can also set a handler for this event on self.onfetch
.Message API
message
is fired when the service worker receives data via postMesssage()
. You can also set a handler for this event on self.onmessage
.notificationclick
is fired when the system reports to the browser that a notification spawned by ServiceWorkerRegistration.showNotification()
was clicked. You can also set a handler for this event on self.onnotificationclick
.notificationclose
is fired when the system reports to the browser that a notification spawned by ServiceWorkerRegistration.showNotification()
was closed or dismissed. You can also set a handler for this event on self.onnotificationclose
.Push API
push
is fired when the service worker receives a push message. You can also set a handler for this event on self.onpush
.pushsubscriptionchange
is fired when there is a change in push subscription state that occurred outside the control of the application (not explicitly in JavaScript). You can also set a handler for this event on self.onpushsubscriptionchange
.Service workers will only intercept requests from clients that are inside the service worker's scope. The scope is defined relative to the path from which the service worker's script was served. If not specified inside register()
, the scope becomes the path to the service worker script.
(All the service worker registrations in these examples use absolute URLs for the script to avoid path confusion.) This first example demonstrates a default root scope for a worker script served from the root path:
navigator.serviceWorker.register('/serviceWorker.js')
.then((serviceWorkerRegistration) => {
console.log(serviceWorkerRegistration.scope);
// https://example.com/
});
// All of the following would be intercepted:
// fetch('/foo.js');
// fetch('/foo/fooScript.js');
// fetch('/baz/bazScript.js');
The following example demonstrates a same-directory scope for a worker script served from the root path:
navigator.serviceWorker.register('/serviceWorker.js', {scope: './'})
.then((serviceWorkerRegistration) => {
console.log(serviceWorkerRegistration.scope);
// https://example.com/
});
// All of the following would be intercepted:
// fetch('/foo.js');
// fetch('/foo/fooScript.js');
// fetch('/baz/bazScript.js');
The following example demonstrates a restricted scope for a worker script served from the root path:
navigator.serviceWorker.register('/serviceWorker.js', {scope: './foo'})
.then((serviceWorkerRegistration) => {
console.log(serviceWorkerRegistration.scope);
// https://example.com/foo/
});
// All of the following would be intercepted:
// fetch('/foo/fooScript.js');
// All of the following would not be intercepted:
// fetch('/foo.js');
// fetch('/baz/bazScript.js');
The following example demonstrates a same-directory scope for a worker script served from a nested path:
navigator.serviceWorker.register('/foo/serviceWorker.js')
.then((serviceWorkerRegistration) => {
console.log(serviceWorkerRegistration.scope);
// https://example.com/foo/
});
// All of the following would be intercepted:
// fetch('/foo/fooScript.js');
// All of the following would not be intercepted:
// fetch('/foo.js');
// fetch('/baz/bazScript.js');
The service worker scope effectively follows a directory permissions model in that it is only possible to reduce the scope of the service worker relative to where the file was served from. Attempting to expand the scope as follows throws an error:
navigator.serviceWorker.register('/foo/serviceWorker.js', {scope: '/'});
// Error: The path of the provided scope 'https://example.com/'
// is not under the max scope allowed 'https://example.com/foo/'
Typically, service worker scope will be defined as an absolute path with a trailing slash, as follows:
navigator.serviceWorker.register('/serviceWorker.js', {scope: '/foo/'})
This style of scope path definition accomplishes two tasks: it decouples the relative path of the script file from the relative scope path, and it prevents the path itself from being included in the scope. For example, in the preceding code snippet, it is probably undesirable for the /foo
path to be included in the service worker scope; appending a trailing /
will explicitly exclude the /foo
path. Of course, this requires that the absolute scope path does not expand outside the service worker path.
If you wish to expand the scope of the service worker, there are two primary ways of doing so:
Service-Worker-Allowed
header to the service worker script response with its value set to the desired scope. This scope value should match the scope value inside register()
.Before service workers, web pages lacked a robust mechanism for caching network requests. Browsers have always made use of an HTTP cache, but this has no programmatic interface available inside JavaScript, and its behavior is governed outside the JavaScript runtime. It was possible to develop an ad-hoc caching mechanism, which cached the response string or blob, but such strategies were messy and inefficient.
JavaScript cache implementations have been tried before. The MDN docs describe it marvelously:
The previous attempt—AppCache—seemed to be a good idea because it allowed you to specify assets to cache really easily. However, it made many assumptions about what you were trying to do and then broke horribly when your app didn't follow those assumptions exactly.
One of the major features of service workers is a true network request caching mechanism that can be programmatically managed. Unlike the HTTP cache or a CPU cache, the service worker cache is fairly primitive:
At its core, the service worker cache mechanism is a two-tier dictionary in which each entry in the top-level dictionary maps to a second nested dictionary. The top-level dictionary is the CacheStorage
object, which is available on the global scope of a service worker via the caches
property. Each value in this top-level dictionary is a Cache
object, which is a dictionary of Request
objects mapping to Response
objects.
As with LocalStorage
, Cache
objects inside CacheStorage
persist indefinitely and will survive past the end of a browser session. Furthermore, Cache
entries are only accessible on a per-origin basis.
The CacheStorage
object is a key-value store of string keys mapping to Cache
objects. The CacheStorage
object features an API that resembles an asynchronous Map
. The CacheStorage
interface is available on the global object via its caches
property.
console.log(caches); // CacheStorage {}
Individual caches inside CacheStorage
are retrieved by passing their string key to caches.open()
. Non-string keys are converted to a string. If the cache does not yet exist, it will be created.
The Cache
object is returned in a promise:
caches.open('v1').then(console.log);
// Cache {}
Similar to a Map
, CacheStorage
features has()
, delete()
, and keys()
methods. These methods all behave as promise-based analogues of their Map
counterparts:
CACHESTORAGEEXAMPLE01.JS
// open a new v1 cache,
// check for the v1 cache,
// check for the nonexistent v2 cache
caches.open('v1')
.then(() => caches.has('v1'))
.then(console.log) // true
.then(() => caches.has('v2'))
.then(console.log); // false
CACHESTORAGEEXAMPLE02.JS
// open a new v1 cache,
// check for the v1 cache,
// delete the v1 cache,
// check again for the deleted v1 cache
caches.open('v1')
.then(() => caches.has('v1'))
.then(console.log) // true
.then(() => caches.delete('v1'))
.then(() => caches.has('v1'))
.then(console.log); // false
CACHESTORAGEEXAMPLE03.JS
// open a v1, v3, and v2 cache
// check keys of current caches
// NOTE: cache keys are printed in creation order
caches.open('v1')
.then(() => caches.open('v3'))
.then(() => caches.open('v2'))
.then(() => caches.keys())
.then(console.log); // ["v1", "v3", "v2"]
The CacheStorage
interface also features a match()
method that can be used to check a Request
object against allCache
objects in CacheStorage
. The Cache
objects are checked in CacheStorage.keys()
order, and the first match is the response returned:
CACHESTORAGEEXAMPLE04.JS
// Create one request key and two response values
const request = new Request('');
const response1 = new Response('v1');
const response2 = new Response('v2');
// Use same key in both caches. v1 is found first since it has
// caches.keys() order priority
caches.open('v1')
.then((v1cache) => v1cache.put(request, response1))
.then(() => caches.open('v2'))
.then((v2cache) => v2cache.put(request, response2))
.then(() => caches.match(request))
.then((response) => response.text())
.then(console.log); // v1
CacheStorage.match()
can be configured using an options object. This object is detailed in the following section, “The Cache Object.”
A CacheStorage
maps strings to Cache
objects. Cache
objects behave similarly to CacheStorage
in that they, too, resemble an asynchronous Map
. Cache
keys can either be a URL string or a Request
object; these keys will map to Response
object values.
The service worker cache is intended to only cache GET http requests. This should make sense: this HTTP method implies that the response will not change over time. On the other hand, request methods such as POST, PUT, and DELETE are, by default, disallowed by the Cache
. They imply a dynamic exchange with the server and therefore are unsuitable for caching by the client.
To populate a Cache
, you have three methods at your disposal:
put(request, response)
—Used when you already have both the key (a Request
object or URL string) and value (Response
object) pair and wish to add the cache entry. This method returns a promise, which resolves when the cache entry is successfully added.add(request)
—Used when you have only a Request
object or URL. add()
will dispatch a fetch()
to the network and cache the response. This method returns a promise, which resolves when the cache entry is successfully added.addAll(requests)
—Used when you wish to perform an all-or-nothing bulk addition to the cache—for example, the initial population of the cache when the service worker initializes. The method accepts an array of URLs or Request
objects. addAll()
performs an add()
operation for each entry in the requests array. This method returns a promise, which resolves only when every cache entry is successfully added.Similar to a Map
, Cache
features delete()
and keys()
methods. These methods all behave as promise-based analogues of their Map
counterparts:
const request1 = new Request('https://www.foo.com');
const response1 = new Response('fooResponse');
caches.open('v1')
.then((cache) => {
cache.put(request1, response1)
.then(() => cache.keys())
.then(console.log) // [Request]
.then(() => cache.delete(request1))
.then(() => cache.keys())
.then(console.log); // []
});
To check a Cache
, you have two methods at your disposal:
matchAll(request, options)
—Returns a promise, which resolves to an array of matching cache Response
objects./images
directory.match(request, options)
—Returns a promise, which resolves to a matching cache Response
object, or undefined
if there are no cache hits.matchAll(request, options)[0]
.Cache hits are determined by matching URL strings and/or Request URLs. URL strings and Request
objects are interchangeable, as the match is determined by extracting the Request
object's URL. This interchangeability is demonstrated here:
const request1 = 'https://www.foo.com';
const request2 = new Request('https://www.bar.com');
const response1 = new Response('fooResponse');
const response2 = new Response('barResponse');
caches.open('v1').then((cache) => {
cache.put(request1, response1)
.then(() => cache.put(request2, response2))
.then(() => cache.match(new Request('https://www.foo.com')))
.then((response) => response.text())
.then(console.log) // fooResponse
.then(() => cache.match('https://www.bar.com'))
.then((response) => response.text())
.then(console.log); // barResponse
});
The Cache
object makes use of the Request
and Response
objects' clone()
method to create duplicates and store them as the key-value pair. This is demonstrated here, where the retrieved instances do not match the original key-value pair:
const request1 = new Request('https://www.foo.com');
const response1 = new Response('fooResponse');
caches.open('v1')
.then((cache) => {
cache.put(request1, response1)
.then(() => cache.keys())
.then((keys) => console.log(keys[0] === request1)) // false
.then(() => cache.match(request1))
.then((response) => console.log(response === response1)); // false
});
Cache.match()
, Cache.matchAll()
, and CacheStorage.matchAll()
all support an optional options
object, which allows you to configure how the URL matching behaves by setting the following properties:
cacheName
—Only supported by CacheStorage.matchAll()
. When set to a string, it will only match cache values inside the Cache
keyed by the provided string.ignoreSearch
—When set to true
, directs the URL matcher to ignore query strings, both in the request query and the cache key. For example, https://example.com?foo=bar
and https://example.com
would match.ignoreMethod
—When set to true
, directs the URL matcher to ignore the http method of the request query. Consider the following example where a POST request can be matched to a GET:
const request1 = new Request('https://www.foo.com');
const response1 = new Response('fooResponse');
const postRequest1 = new Request('https://www.foo.com',
{ method: 'POST' });
caches.open('v1')
.then((cache) => {
cache.put(request1, response1)
.then(() => cache.match(postRequest1))
.then(console.log) // undefined
.then(() => cache.match(postRequest1, { ignoreMethod: true }))
.then(console.log); // Response {}
});
ignoreVary
—The Cache
matcher respects the Vary
HTTP header, which specifies which request headers may cause the server response to differ. When ignoreVary
is set to true
, this directs the URL matcher to ignore the Vary
header when matching.
const request1 = new Request('https://www.foo.com');
const response1 = new Response('fooResponse',
{ headers: {'Vary': 'Accept' }});
const acceptRequest1 = new Request('https://www.foo.com',
{ headers: { 'Accept': 'text/json' } });
caches.open('v1')
.then((cache) => {
cache.put(request1, response1)
.then(() => cache.match(acceptRequest1))
.then(console.log) // undefined
.then(() => cache.match(acceptRequest1, { ignoreVary: true }))
.then(console.log); // Response {}
});
Browsers need to restrict the amount of storage any given cache is allowed to use; otherwise, unlimited storage would surely be subject to abuse. This storage limit does not follow any formal specification; it is entirely subject to the individual browser vendor's preference.
Using the StorageEstimate API, it is possible to determine approximately how much space is available (in bytes) and how much is currently used. This method is only available in a secure browser context:
navigator.storage.estimate()
.then(console.log);
// Your browser's output will differ:
// { quota: 2147483648, usage: 590845 }
Per the service worker specification:
These are not exact numbers; between compression, deduplication, and obfuscation for security reasons, they will not be precise.
A service worker tracks an association with a window, worker, or service worker with a Client
object. Service workers can access these Client
objects via the Clients
interface, available on the global object via the self.clients
property.
A Client
object features the following properties and methods:
id
—Returns the universally unique identifier for this client, such as 7e4248ec-b25e-4b33-b15f-4af8bb0a3ac4
. This can be used to retrieve a reference to the client via Clients.get()
.type
—Returns the type of the client as a string. Its value will be one of window
, worker
, or sharedworker
.url
—Returns the client's URL.postMessage()
—Allows you to send targeted messaging to a single client.The Clients
interface allows you to access Client
objects via get()
and matchAll()
, both of which use a promise to return results. matchAll()
can also be passed an options
object that supports the following properties:
includeUncontrolled
—When set to true
, returns clients that are not yet controlled by this service worker. Defaults to false
.type
—When set to window
, worker
, or sharedworker
, filters returned clients to only that type. Defaults to all
, which returns all types of clients.The Clients
interface also provides two methods:
openWindow(url)
—Allows you to open a new window at the specified URL, effectively adding a new Client
to this service worker. The new Client
object is returned in a resolved promise. This method is useful when a notification is clicked; the service worker can detect the click event and open a window in response to that click.claim()
—Will forcibly set this service worker to control all clients in its scope. This is useful when you do not wish to wait for a page reload for the service worker to begin managing the page.Service workers should be understood through the lens of their overall intended purpose: to enable web pages to emulate native application behavior. Behaving like a native application demands that service workers support versioning.
At a high level, service worker versioning ensures that there is consistency between how two web pages on the same origin operate at any given time. This consistency guarantee takes two primary forms:
LocalStorage
or IndexedDB
. They can also send and receive data to remote APIs. The format that data is read or written in may change between versions. If one page writes data in a version 1 format, but a second page attempts to read data in a version 2 format, the resulting behavior is completely unpredictable and likely incorrect. The service worker's asset consistency mechanism also ensures that web page I/O behaves identically for all concurrently running pages on the same origin.To preserve consistency, the service worker lifecycle goes to great lengths to avoid reaching a state that might compromise this consistency. For example:
importScripts()
, or failing to load even a single cache asset.register()
or on a page reload), browsers will begin installation of a new service worker version if there is even a single byte of difference between the service worker script or any dependencies loaded via importScripts()
.register()
is invoked for the first time on a page, the service worker is installed but will not be activated and begin to control the page until after a navigation event. This should make sense: the current page has presumably already loaded assets, so the service worker should not be activated and begin loading inconsistent assets.The service worker specification defines six discrete states that a service worker might exist in: parsed, installing, installed, activating, activated, and redundant. A full lifecycle for a service worker will always visit these states in this order, although it may not visit every state. A service worker that encounters an error during installation or activation will skip to the redundant state.
Each state change will fire a statechange
event on the ServiceWorker
object. A handler can be set to listen for this event as follows:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
registration.installing.onstatechange = ({ target: { state } }) => {
console.log('state changed to', state);
};
});
A call to navigator.serviceWorker.register()
will initiate the process of creating a service worker instance. The parsed state is assigned to that freshly created service worker. This state has no events or ServiceWorker.state
value associated with it.
The browser fetches the script file and performs some initial tasks to begin the lifecycle:
If all these succeed, the promise returned from register()
will resolve with a ServiceWorkerRegistration
object, and a newly created service worker instance proceeds to the installing state.
The installing state is where all service worker "setup" tasks should be performed. This includes work that must occur prior to the service worker controlling the page.
On the client, this phase can be identified by checking to see if the ServiceWorkerRegistration.installing
property is set to a ServiceWorker
instance:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
if (registration.installing) {
console.log('Service worker is in the installing state');
}
});
The associated ServiceWorkerRegistration
object will also fire the updatefound
event any time a service worker reaches this state:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
registration.onupdatefound = () =>
console.log('Service worker is in the installing state');
};
});
In the service worker, this phase can be identified by setting a handler for the install
event:
self.oninstall = (installEvent) => {
console.log('Service worker is in the installing state');
};
The installing state is frequently used to populate the service worker's cache. The service worker can be directed to remain in the installing state until a collection of assets is successfully cached. If any of the assets fail to cache, the service worker will fail to install and will be sent to the redundant state.
The service worker can be held in the installing state by means of an ExtendableEvent
. The InstallEvent
inherits from ExtendableEvent
and therefore exposes an API, which allows you to delay a state transition until a promise resolves. This is accomplished with the ExtendableEvent.waitUntil()
method. This method expects to be passed a promise that will delay transitioning to the next state until that promise resolves. For example, the following example would delay transitioning to the installed state by five seconds:
self.oninstall = (installEvent) => {
installEvent.waitUntil(
new Promise((resolve, reject) => setTimeout(resolve, 5000))
);
};
A more pragmatic use of this method would be to cache a group of assets via Cache.addAll()
:
const CACHE_KEY = 'v1';
self.oninstall = (installEvent) => {
installEvent.waitUntil(
caches.open(CACHE_KEY)
.then((cache) => cache.addAll([
'foo.js',
'bar.html',
'baz.css',
]))
);
};
If there is no error thrown or promise rejected, the service worker proceeds to the installed state.
The installed state, also referred to as the waiting state, indicates that the service worker has no additional setup tasks to perform and that it is prepared to assume control of clients once it is allowed to do so. If there is no active service worker, a freshly installed service worker will skip this state and proceed directly to the activating state since there is no reason to wait.
On the client, this phase can be identified by checking to see if the ServiceWorkerRegistration.waiting
property is set to a ServiceWorker
instance:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
if (registration.waiting) {
console.log('Service worker is in the installing/waiting state');
}
});
If there is already an active service worker, the installed state can be the appropriate time to trigger logic, which will promote this new service worker to the activating state. This might take the form of forcibly promoting this service worker via self.skipWaiting()
. It also might take the form of prompting the user to reload the application, thereby allowing the browser to organically promote the service worker.
The activating state indicates that the service worker has been selected by the browser to become the service worker that should control the page. If there is no incumbent active service worker in the browser, this new service worker will automatically reach the activating state. If, however, there is an incumbent active service worker, this new replacement service worker can reach the activating state in the following ways:
self.skipWaiting()
. This takes effect immediately and does not need to wait for a navigation event.While in the activating state, no functional events such as fetch or push are dispatched until the service worker reaches the activated state.
On the client, this phase can be partially identified by checking to see if the ServiceWorkerRegistration.active
property is set to a ServiceWorker
instance:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
if (registration.active) {
console.log('Service worker is in the activating/activated state');
}
});
Note that the ServiceWorkerRegistration.active
property indicates that the service worker is in either the activating or activated state.
In the service worker, this phase can be identified by setting a handler for the activate
event:
self.oninstall = (activateEvent) => {
console.log('Service worker is in the activating state');
};
The activate
event indicates that it is safe to clean up after the old service worker, and this event is frequently used to purge old cache data and migrate databases. For example, the following example purges all older cache versions:
const CACHE_KEY = 'v3';
self.oninstall = (activateEvent) => {
caches.keys()
.then((keys) => keys.filter((key) => key != CACHE_KEY))
.then((oldKeys) => oldKeys.forEach((oldKey) => caches.delete(oldKey));
};
An activate
event also inherits from ExtendableEvent
and therefore also supports the waitUntil()
convention for delaying a transition to the activated state—or transitioning to the redundant state upon a rejected promise.
The activated state indicates that the service worker is in control of one or many clients. In this state, the service worker will capture fetch()
events inside its scope as well as notification and push events.
On the client, this phase can be partially identified by checking to see if the ServiceWorkerRegistration.active
property is set to a ServiceWorker
instance:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
if (registration.active) {
console.log('Service worker is in the activating/activated state');
}
});
Note that the ServiceWorkerRegistration.active
property indicates that the service worker is in either the activating or activated state.
A superior indication that a service worker is in the activated state is to check the controller
property of the ServiceWorkerRegistration
. This will return the activated ServiceWorker
instance, which is controlling the page:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
if (registration.controller) {
console.log('Service worker is in the activated state');
}
});
When a new service worker takes control of a client, the ServiceWorkerContainer
in that client will fire a controllerchange
event:
navigator.serviceWorker.oncontrollerchange = () => {
console.log('A new service worker is controlling this client');
};
It's also possible to use the ServiceWorkerContainer.ready
promise to detect an active service worker. This ready
promise resolves once the current page has an active worker:
navigator.serviceWorker.ready.then(() => {
console.log('A new service worker is controlling this client');
});
The redundant state is the graveyard for service workers. No events will be passed to it, and the browser is free to destroy it and free up its resources.
Because the concept of versioning is baked into service workers, they are expected to periodically change. Therefore, service workers feature a robust and intricate updating process for safely replacing an outdated active service worker.
This update process begins with an update check, where the browser re-requests the service worker script. A check for an update can be triggered by the following events:
navigator.serviceWorker.register()
is called with a different URL string than the currently active service worker.fetch
or push
occurs and an update check has not occurred for at least 24 hours.The freshly fetched service worker script is diffed against the incumbent service worker's script. If they are not identical, the browser initializes a new service worker with the new script. The updated service worker will proceed through its lifecycle until it reaches the installed state. Once it reaches the installed state, the updated service worker will wait until the browser decides it can safely obtain control of the page (or until the user forces it to take control of the page).
Importantly, refreshing a page will not allow the updated service worker to activate and replace the incumbent service worker. Consider a scenario where there is a single page open with an incumbent service worker controlling it and an updated service worker waiting in the installed state. Clients overlap during a page refresh—meaning the new page is loaded before the old page dies—and therefore the incumbent service worker never relinquishes control because it still controls a nonzero number of clients. Because of this, closing all controlled pages is the only way to allow the incumbent service worker to be replaced.
Whereas dedicated and shared workers are designed to be stateful, service workers are designed to be stateless. More specifically, service workers follow the Inversion of Control (IOC) pattern and are built to be event driven.
The primary implication of this is that service workers should have no reliance whatsoever on the global state of the worker. Nearly all code inside the service worker should be defined inside event handlers—the notable exception being global constants such as the service worker version. The number of times a service worker script will execute is wildly variable and highly dependent on browser state, and therefore the service worker script's behavior should be idempotent.
It's important to understand that the lifetime of a service worker is uncorrelated with the lifetime of the clients it is connected to. Most browsers implement service workers as a separate process, and this process is independently managed by the browser. If a browser detects a service worker is idle, it can terminate the worker and restart it again when needed. This means that, while you can rely on a service worker to handle events once activated, you cannot rely on a service worker's persistent global state.
Normally, all JavaScript assets loaded by the browser are subject to the browser's HTTP cache as defined by their Cache-Control
header. Because service worker scripts are not given preferential treatment, the browser will not receive updated service worker script updates until the cached file expires.
To propagate service worker updates as quickly as possible, a common solution is to serve service worker scripts with a Cache-Control: max-age=0
header. With this, the browser will always fetch the most up-to-date script file.
This instant-expiry solution works well, but solely relying on HTTP headers to dictate service worker behavior means that only the server decides how the client should update. To allow the client agency over its updating behavior, the updateViaCache
property exists to allow for control over how the client should treat service worker scripts. This property can be defined when registering the service worker, and it accepts three string values:
imports
: the default value. The top-level service worker script file will never be cached, but files imported inside the service worker via importScripts()
will still be subject to the HTTP cache and Cache-Control
header.all
: No service worker scripts are given special treatment. All files are subject to the HTTP cache and Cache-Control
header.none
: Both the top-level service worker script and files imported inside the service worker via importScripts()
will never be cached.The updateViaCache
property is used as follows:
navigator.serviceWorker.register('/serviceWorker.js', {
updateViaCache: 'none'
});
Browsers are still in the process of moving to support this option, so it is strongly recommended that you use both updateViaCache
and the Cache-Control
header to dictate caching behavior on the client.
In some cases, it makes sense to coerce a service worker into the activated state as quickly as possible—even at the expense of potential asset versioning conflicts. This commonly takes the form of caching assets at the install event, forcing the service worker to activate, and then forcing the activated service worker to control the associated clients.
A basic version of this might appear as follows:
const CACHE_KEY = 'v1';
self.oninstall = (installEvent) => {
// Populate the cache, then force the service worker
// into the activated state. This triggers the 'activate' event.
installEvent.waitUntil(
caches.open(CACHE_KEY)
.then((cache) => cache.addAll([
'foo.css',
'bar.js',
]))
.then(() => self.skipWaiting())
);
};
// Force the service worker to take control of the clients. This fires a
// controllerchange event on each client.
self.onactivate = (activateEvent) => clients.claim();
Browsers will check for a new service worker script on each navigation event, but sometimes this is too infrequent. The ServiceWorkerRegistration
object features an update()
method that can be used to instruct the browser to re-request the service worker script, compare it to the existing one, and begin installation of an updated service worker if necessary. This might be accomplished as follows:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
// Check for an updated version every ~17 minutes
setInterval(() => registration.update(), 1E6);
});
As with dedicated workers and shared workers, service workers are able to exchange asynchronous messages with clients using postMessage()
. One of the simplest ways of accomplishing this is to send a message to the active worker and use the event object to send a reply. Messages sent to the service worker can be handled on the global scope, whereas messages sent back to the client can be handled on the ServiceWorkerContext
object:
SERVICEWORKER.JS
self.onmessage = ({data, source}) => {
console.log('service worker heard:', data);
source.postMessage('bar');
};
MAIN.JS
navigator.serviceWorker.onmessage = ({data}) => {
console.log('client heard:', data);
};
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
if (registration.active) {
registration.active.postMessage('foo');
}
});
// service worker heard: foo
// client heard: bar
This can just as easily use the serviceWorker.controller
property:
SERVICEWORKER.JS
self.onmessage = ({data, source}) => {
console.log('service worker heard:', data);
source.postMessage('bar');
};
MAIN.JS
navigator.serviceWorker.onmessage = ({data}) => {
console.log('client heard:', data);
};
navigator.serviceWorker.register('./serviceWorker.js')
.then(() => {
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('foo');
}
});
// service worker heard: foo
// client heard: bar
The preceding examples will work every time the page reloads, as the service worker will reply to the new message sent from the client script after each reload. It will also work every time this page is opened in a new tab.
If, instead, the service worker should initiate the message handshake, a reference to the client can be obtained as follows:
SERVICEWORKER.JS
self.onmessage = ({data}) => {
console.log('service worker heard:', data);
};
self.onactivate = () => {
self.clients.matchAll({includeUncontrolled: true})
.then((clientMatches) => clientMatches[0].postMessage('foo'));
};
MAIN.JS
navigator.serviceWorker.onmessage = ({data, source}) => {
console.log('client heard:', data);
source.postMessage('bar');
};
navigator.serviceWorker.register('./serviceWorker.js')
// client heard: foo
// service worker heard: bar
The preceding example will only work once, as the active event is only fired a single time per service worker.
Because clients and service workers can send messages back and forth, it is also possible to set up a MessageChannel
or BroadcastChannel
to exchange messages.
One of the most important features of service workers is their ability to intercept network requests. A network request inside the scope of a service worker will register as a fetch
event. This interception ability is not limited to the fetch()
method; it also can intercept requests for JavaScript, CSS, images, and HTML—including the primary HTML document itself. These requests can come from JavaScript, or they can be requests created by tags such as <script>
, <link>
, or <img>
tags. Intuitively, this should make sense: for a service worker to emulate an offline web app, it must be able to account for all the requested assets needed for the page to function properly.
A FetchEvent
inherits from ExtendableEvent
. The valuable method that allows service workers to decide how to handle a fetch
event is event.respondWith()
. This method expects a promise, which should resolve with a Response
object. Of course, your service worker gets to decide where this Response
object actually comes from. It could be from the network, from the cache, or created on the fly. The following sections cover a handful of network/cache strategies to employ inside a service worker.
This strategy is a simple passthrough for a fetch
event. A good use case for this might be any requests that definitely need to reach the server, such as a POST request. This strategy can be implemented as follows:
self.onfetch = (fetchEvent) => {
fetchEvent.respondWith(fetch(fetchEvent.requeest));
};
This strategy is a simple cache check. A good use case for this might be any requests that are guaranteed to be in the cache—such as assets cached during the installation phase.
self.onfetch = (fetchEvent) => {
fetchEvent.respondWith(caches.match(fetchEvent.request));
};
This strategy gives preference to up-to-date responses from the network but will still return values in the cache if they exist. A good use case for this is when your application needs to show the most up-to-date information as often as possible, but would still like to show something if the application is offline.
self.onfetch = (fetchEvent) => {
fetchEvent.respondWith(
fetch(fetchEvent.request)
.catch(() => caches.match(fetchEvent.request))
);
};
This strategy gives preference to responses it can show more quickly but will still fetch from the network if a value is uncached. This is the superior fetch handling strategy for most progressive web applications.
self.onfetch = (fetchEvent) => {
fetchEvent.respondWith(
caches.match(fetchEvent.request)
.then((response) => response || fetch(fetchEvent.request))
);
};
Applications need to account for scenarios where both the cache and network fail to produce a resource. Service workers can handle this by caching fallback resources upon install and returning them when both cache and network fail.
self.onfetch = (fetchEvent) => {
fetchEvent.respondWith(
// Begin with 'Return from cache with network fallback' stragegy
caches.match(fetchEvent.request)
.then((response) => response || fetch(fetchEvent.request))
.catch(() => caches.match('/fallback.html'))
);
};
The catch()
clause can be extended to support many different types of fallbacks such as placeholder images, dummy data, and so on.
For a web application to properly emulate a native application, it must be able to support push notifications. This means that a web page must be able to receive a push event from a server and display a notification on the device—even when the application is not running. With conventional web pages, this, of course is impossible, but the addition of service workers means that this behavior is now supported.
For push notifications to work in a progressive web application, four behavioral aspects must be supported:
Service workers have access to the Notification API via their registration object. There is good reason for this: notifications associated with a service worker will also trigger interaction events inside that service worker.
Showing notifications requires explicit permission from the user. Once this is granted, notifications can be shown via ServiceWorkerRegistration.showNotification()
. This can be accomplished as follows:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
Notification.requestPermission()
.then((status) => {
if (status === 'granted') {
registration.showNotification('foo');
}
});
});
Similarly, a notification can be triggered from inside a service worker using the global registration
property:
self.onactivate = () => self.registration.showNotification('bar');
In these examples, once notification permissions are granted, a foo
notification is shown in the browser. This notification will be visually indistinguishable from one generated using new Notification()
. Furthermore, it doesn't require the service worker to do any work for it to appear. The service worker comes into play when notification events are required.
A notification created via a ServiceWorkerRegistration
object will send notificationclick
and notificationclose
events to the service worker. Suppose the previous example's service worker script was defined as follows:
self.onnotificationclick = ({notification}) => {
console.log('notification click', notification);
};
self.onnotificationclose = ({notification}) => {
console.log('notification close', notification);
};
In this example, both types of interactions with a notification will register inside the service worker. The notificationevent
exposes a notification
property, containing the Notification
object that generated the event. These event handlers can decide what to do after an interaction.
Frequently, clicking a notification means the user wishes to be taken to a specific view. Inside the service worker handler, this can be accomplished via clients.openWindow()
, shown here:
self.onnotificationclick = ({notification}) => {
clients.openWindow('https://foo.com');
};
For push messages to be sent to a service worker, the subscription must happen via the service worker's PushManager
. This will allow the service worker to handle push messages in a push
event handler.
The subscription can be done using ServiceWorkerRegistration.pushManager
, as shown here:
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
registration.pushManager.subscribe({
applicationServerKey: key, // derived from server's public key
userVisibleOnly: true
});
});
Alternately, the service worker can subscribe itself using the global registration
property:
self.onactivate = () => {
self.registration.pushManager.subscribe({
applicationServerKey: key, // derived from server's public key
userVisibleOnly: true
});
};
Once subscribed, the service worker will receive push
events each time the server pushes a message. They can be handled as follows:
self.onpush = (pushEvent) => {
console.log('Service worker was pushed data:', pushEvent.data.text());
};
To implement a true push notification, this handler only needs to create a notification via the registration object. However, a well-behaved push notification needs to keep the service worker that produced it alive long enough for its interaction event to be handled.
To accomplish this, the push
event inherits from ExtendableEvent
. The promise returned from showNotification()
can be passed to waitUntil()
, which will keep the service worker alive until the notification's promise resolves.
A simple push notification implementation might appear as follows:
MAIN.JS
navigator.serviceWorker.register('./serviceWorker.js')
.then((registration) => {
// Request permission to show notifications
Notification.requestPermission()
.then((status) => {
if (status === 'granted') {
// Only subscribe to push messages if
// notification permission is granted
registration.pushManager.subscribe({
applicationServerKey: key, // derived from server's public key
userVisibleOnly: true
});
}
});
});
SERVICEWORKER.JS
// When a push event is received, display the data as text
// inside a notification.
self.onpush = (pushEvent) => {
// Keep the service worker alive until notification promise resolves
pushEvent.waitUntil(
self.registration.showNotification(pushEvent.data.text())
);
};
// When a notification is clicked, open relevant application page
self.onnotificationclick = ({notification}) => {
clients.openWindow('https://example.com/clicked-notification');
};
Workers allow you to run asynchronous JavaScript that will not block the user interface. This is very useful for complex calculations and data processing that would otherwise take up a lot of time and interfere with the user's ability to use the page. Workers are always given their own execution environment, and can only be communicated with via asynchronous messaging.
Workers can be dedicated, meaning they are associated with a single page, or shared, meaning that any page on the same origin can establish a connection with a single worker.
Service workers are designed to allow web pages to behave like native apps. Service workers are also a type of worker, but they behave more like a network proxy than a separate browser thread. They can behave as a highly customizable network cache, and they also can enable push notifications for progressive web applications.