27
Workers

WHAT'S IN THIS CHAPTER?

  • Introduction to workers
  • Running background tasks with dedicated workers
  • Using with shared workers
  • Managing requests with service workers

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.

INTRODUCTION TO WORKERS

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.

Comparing Workers and Threads

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:

  • Workers are implemented as actual threads. For example, the Blink browser engine implements workers with a WorkerThread that corresponds to an underlying platform thread.
  • Workers execute in parallel. Even though the page and the worker both implement the single-threaded JavaScript environment, instructions in each environment can be executed in parallel.
  • Workers can share some memory. Workers are able to use a 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:

  • Workers do not share all memory. In a traditional thread model, multiple threads have the capability to read and write to a shared memory space. With the exception of the SharedArrayBuffer, moving data in and out of workers requires it to be copied or transferred.
  • Worker threads are not necessarily part of the same process. Typically, a single process is able to spawn multiple threads inside of it. Depending on the browser engine's implementation, a worker thread may or may not be part of the same process as the page. For example, Chrome's Blink engine uses a separate process for shared worker and service worker threads.
  • Worker threads are more expensive to create. Worker threads include their own separate event loop, global objects, event handlers, and other features that are part and parcel of a JavaScript environment. The computational expense of creating these should not be overlooked.

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.

Types of Workers

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.

Dedicated Web Worker

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.

Shared Web Worker

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.

Service 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.

The WorkerGlobalScope

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.

WorkerGlobalScope Properties and Methods

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.

Subclasses of WorkerGlobalScope

The WorkerGlobalScope is not actually implemented anywhere. Each type of worker uses its own flavor of global object, which inherits from WorkerGlobalScope.

  • A dedicated worker uses a DedicatedWorkerGlobalScope.
  • A shared worker uses a SharedWorkerGlobalScope.
  • A service worker uses a ServiceWorkerGlobalScope.

The differences between these global objects are discussed in their respective sections in this chapter.

DEDICATED WORKERS

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 Worker Basics

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.

Creating a Dedicated Worker

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.JS console.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:

  • The emptyWorker.js file is loaded from an absolute path. Depending on the structure of your application, using an absolute URL will often be redundant.
  • This file is loaded in the background, and the worker initialization occurs on a thread totally separate from that of main.js.
  • The worker itself exists in a separate JavaScript environment, so the 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.
  • Although the worker itself may not yet exist, this 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 Security Restrictions

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.

Using the Worker Object

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.
    • This event occurs when an error is thrown inside the worker.
    • This event can also be handled using 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.
    • This event occurs when the worker script sends a message event back to the parent context.
    • This event can also be handled using 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.
    • This event occurs when a message is received that cannot be deserialized.
    • This event can also be handled using 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.

The DedicatedWorkerGlobalScope

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.JS console.log('inside worker:', self); 
 
MAIN.JS const 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.

Dedicated Workers and Implicit MessagePorts

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.

Understanding the Dedicated Worker Lifecycle

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.JS self.onmessage = ({data}) => console.log(data);
 
MAIN.JS const 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.

Configuring Worker Options

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.

Creating a Worker from Inline JavaScript

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.

Dynamic Script Execution Inside a Worker

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'); 

Delegating Tasks to Subworkers

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');  

Handling Worker Errors

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'); 

Communicating with a Dedicated Worker

All communication to and from a worker occurs via asynchronous messages, but these messages can take on a handful of different forms.

Communicating with postMessage()

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.

Communicating with MessageChannel

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.

Communicating with BroadcastChannel

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.

Worker Data Transfer

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.

Structured Clone Algorithm

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:

  • All primitive types except Symbol
  • Boolean object
  • String object
  • BDate
  • RegExp
  • Blob
  • File
  • FileList
  • ArrayBuffer
  • ArrayBufferView
  • ImageData
  • Array
  • Object
  • Map
  • Set

Some things to note about the behavior of the structured clone algorithm:

  • Once copied, mutations to the object in the source context will not be propagated to the destination context object.
  • The structured clone algorithm recognizes when an object contains a cycle and will not infinitely traverse the object.
  • Attempting to clone an Error object, a Function object, or a DOM node will throw an error.
  • The structured clone algorithm will not always create an exact copy.
  • Object property descriptors, getters, and setters are not cloned and will revert to defaults where applicable.
  • Prototype chains are not cloned.
  • The RegExp.prototype.lastIndex property is not cloned.

Transferable Objects

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
}; 

SharedArrayBuffer

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:

  1. Worker A reads a value of 1.
  2. Worker B reads a value of 1.
  3. Worker A adds 1 and writes 2 back into the array.
  4. Worker B, still using the stale array value of 1, writes 2 back into the array.

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);
}; 

Worker Pools

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 

SHARED WORKERS

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.

Shared Worker Basics

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.

Creating a Shared Worker

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.

SharedWorker Identity and Single Occupancy

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.

Using the SharedWorker Object

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.
    • This event occurs when an error is thrown inside the worker.
    • This event can also be handled using sharedWorker.addEventListener('error', handler).
  • port—The dedicated MessagePort for communication with the shared worker.

The SharedWorkerGlobalScope

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.
    • The connect event occurs when a connection is made to the shared worker either via worker.port.onmessage or worker.port.start().
    • This event can also be handled using sharedWorker.addEventListener('connect', handler).

Understanding the Shared Worker Lifecycle

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.

Connecting to a Shared 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.

SERVICE WORKERS

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.

Service Worker Basics

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.

The ServiceWorkerContainer

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 { … } 

Creating a Service Worker

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.

Using the ServiceWorkerContainer Object

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.
    • This event occurs when a new activated ServiceWorkerRegistration is acquired.
    • This event can also be handled using 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.
    • This event occurs when an error is thrown inside any associated service worker.
    • This event can also be handled using 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.
    • This event occurs when the service worker script sends a message event back to the parent context.
    • This event can also be handled using 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().

Using the ServiceWorkerRegistration Object

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.
    • This event occurs when a new version of this service worker begins installation, signified by ServiceWorkerRegistration.installing acquiring a new service worker.
    • This event can also be handled using 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.

Using the ServiceWorker Object

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.
    • This event occurs when ServiceWorker.state changes.
    • This event can also be handled using 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

Service Worker Security Restrictions

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.

The ServiceWorkerGlobalScope

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.
    • This is the first event received by a service worker and is fired as soon as worker execution begins.
    • Called only once per service worker.
  • 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.
    • This event is fired when the service worker is ready to handle functional events and control clients.
    • This event does not mean that the service worker is controlling a client, only that it is prepared to do so.

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.
  • Notification API.
  • 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 Worker Scope Limitations

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:

  • Serve the service worker script from a path that encompasses the desired scope.
  • Add a 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().

The Service Worker Cache

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:

  • The service worker cache does not cache any requests automatically. All cache entries must be explicitly added.
  • The service worker cache has no concept of time-based expiration. A cache entry will remain cached unless explicitly removed.
  • Service worker cache entries must be manually updated and deleted.
  • Caches must be manually versioned. Each time a service worker updates, the new service worker is responsible for providing a fresh cache key to store new cache entries.
  • The only browser-enforced eviction policy is based on storage available for the service worker cache to use. The service worker is responsible for managing the amount of space its cache uses. When the size of the cache exceeds browser limits, the browser will utilize a least recently used (LRU) eviction policy to make room for new cache entries.

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

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.”

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.
  • This method is useful in scenarios where you wish to perform a bulk action upon similarly organized cache entries, such as deleting all the cached values inside the /images directory.
  • The request matching schema can be configured via an options object, described later in this section.
  • match(request, options)—Returns a promise, which resolves to a matching cache Response object, or undefined if there are no cache hits.
  • This is essentially equivalent to matchAll(request, options)[0].
  • The request matching schema can be configured via an options object, described later in this section.

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 {}
    });

Maximum Cache Storage

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.

Service Worker Clients

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 and Consistency

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:

  • Code consistency—Web pages are not created from a single binary like a native application, but instead from many HTML, CSS, JavaScript, image, JSON, and really any type of file assets that a page might load. Web pages will commonly undergo incremental upgrades—versions—to add or modify behavior. If a web page loads 100 files in total, and the assets loaded are a mix of versions 1 and 2, the resulting behavior is completely unpredictable and likely incorrect. Service workers provide an enforcement mechanism to ensure that all concurrently running pages on the same origin are always built from assets from the same version.
  • Data consistency—Web pages are not hermetic applications. They can read and write data on the local device via various browser APIs such as 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:

  • Service workers fail early. When attempting to install a service worker, any unexpected problem will prevent the service worker from being installed. This includes failing to load the service worker script, a syntax or runtime error in the service worker script, failing to load a worker dependency via importScripts(), or failing to load even a single cache asset.
  • Service workers aggressively update. When the browser loads the service worker script again (either manually via 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().
  • Inactive service workers passively activate. When 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.
  • Active service workers are sticky. As long as there is at least one client associated with the active service worker, the browser will continue to use it for all pages of that origin. The browser can begin installation of a new service worker instance intended to replace the active one, but the browser will not switch to the new worker until there are 0 clients controlling the active one (or until the service worker is forcibly updated). This service worker eviction strategy prevents two clients from running two different service worker versions at once.

Understanding the Service Worker Lifecycle

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);
 };
});

The Parsed 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:

  1. Ensure the service worker script is served on the same domain.
  2. Ensure the service worker registration occurs inside a secure context.
  3. Ensure the service worker script can be successfully parsed by the browser's JavaScript interpreter without throwing any errors.
  4. Capture a snapshot of the service worker script. The next time the browser downloads the service worker script, it will diff it against this snapshot and use that to decide if it should update the service worker or not.

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

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

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

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:

  • The number of clients controlled by the incumbent service worker goes to 0. This often takes the form of all controlled browser tabs being closed. On the next navigation event, the new service worker will reach the activating state.
  • The installed service worker calls 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

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

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.

Updating a Service Worker

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.
  • The browser navigates to a page inside the service worker's scope.
  • A functional event such as 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.

Inversion of Control and Service Worker Persistence

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.

Managing Service Worker File Caching with updateViaCache

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.

Forced Service Worker Operation

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);
});

Service Worker Messaging

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.

Intercepting a fetch Event

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.

Return from Network

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));
};

Return from Cache

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));
};

Return from Network with Cache Fallback

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))
 );
};

Return from Cache with Network Fallback

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))
 );
};

Generic Fallback

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.

Push Notifications

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:

  • The service worker must be able to display notifications.
  • The service worker must be able to handle interactions with those notifications.
  • The service worker must be able to subscribe to server-sent push notifications.
  • The service worker must be able to handle push messages, even when the application is not in the foreground or open.

Displaying Notifications

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.

Handling Notification Events

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');
};

Subscribing to Push Events

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
 });
};

Handling Push Events

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');
};
 

SUMMARY

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset