ES modules in service workers

A modern alternative to importScripts().

Background

ES modules have been a developer favorite for a while now. In addition to a number of other benefits, they offer the promise of a universal module format where shared code can be released once and run in browsers and in alternative runtimes like Node.js. While all modern browsers offer some ES module support, they don't all offer support everywhere that code can be run. Specifically, support for importing ES modules inside of a browser's service worker is just starting to become more widely available.

This article details the current state of ES module support in service workers across common browsers, along with some gotchas to avoid, and best practices for shipping backwards-compatible service worker code.

Use cases

The ideal use case for ES modules inside of service workers is for loading a modern library or configuration code that's shared with other runtimes that support ES modules.

Attempting to share code in this way prior to ES modules entailed using older "universal" module formats like UMD that include unneeded boilerplate, and writing code that made changes to globally exposed variables.

Scripts imported via ES modules can trigger the service worker update flow if their contents change, matching the behavior of importScripts().

Current limitations

Static imports only

ES modules can be imported in one of two ways: either statically, using the import ... from '...' syntax, or dynamically, using the import() method. Inside of a service worker, only the static syntax is currently supported.

This limitation is analogous to a similar restriction placed on importScripts() usage. Dynamic calls to importScripts() do not work inside of a service worker, and all importScripts() calls, which are inherently synchronous, must complete before the service worker completes its install phase. This restriction ensures that the browser knows about, and is able to implicitly cache, all JavaScript code needed for a service worker's implementation during installation.

Eventually, this restriction might be lifted, and dynamic ES module imports may be allowed. For now, ensure that you only use the static syntax inside of a service worker.

What about other workers?

Support for ES modules in "dedicated" workers—those constructed with new Worker('...', {type: 'module'})—is more widespread, and has been supported in Chrome and Edge since version 80, as well as recent versions of Safari. Both static and dynamic ES module imports are supported in dedicated workers.

Chrome and Edge have supported ES modules in shared workers since version 83, but no other browser offers support at this time.

No support for import maps

Import maps allow runtime environments to rewrite module specifiers, to, for example, prepend the URL of a preferred CDN from which the ES modules can be loaded.

While Chrome and Edge version 89 and above support import maps, they currently cannot be used with service workers.

Browser support

ES modules in service workers are supported in Chrome and Edge starting with version 91.

Safari added support in the Technology Preview 122 Release, and developers should expect to see this functionality released in the stable version of Safari in the future.

Example code

This is a basic example of using a shared ES module in a web app's window context, while also registering a service worker that uses the same ES module:

// Inside config.js:
export const cacheName = 'my-cache';
// Inside your web app:
<script type="module">
  import {cacheName} from './config.js';
  // Do something with cacheName.

  await navigator.serviceWorker.register('es-module-sw.js', {
    type: 'module',
  });
</script>
// Inside es-module-sw.js:
import {cacheName} from './config.js';

self.addEventListener('install', (event) => {
  event.waitUntil((async () => {
    const cache = await caches.open(cacheName);
    // ...
  })());
});

Backwards compatibility

The above example would work fine if all browsers supported ES modules in service workers, but as of this writing, that's not the case.

To accommodate browsers that don't have built-in support, you can run your service worker script through an ES module-compatible bundler to create a service worker that includes all of the module code inline, and will work in older browsers. Alternatively, if the modules you're attempting to import are already available bundled in IIFE or UMD formats, you can import them using importScripts().

Once you have two versions of your service worker available—one that uses ES modules, and the other that doesn't—you'll need to detect what the current browser supports, and register the corresponding service worker script. The best practices for detecting support are currently in flux, but you can follow the discussion in this GitHub issue for recommendations.

_Photo by Vlado Paunovic on Unsplash_