Using Bundlers (webpack/Rollup) with Workbox

One of the core principles we have for Workbox is to make it as small and light-weight as possible. We want Workbox to have lots of features, but we don't want your users to have to download code for features they don't use.

This is why the version of Workbox we release on our CDN is configured to automatically only load the packages you're using (via workbox-sw) when they're referenced. For example, if your code never references workbox.backgroundSync, then the workbox-background-sync package will not be downloaded.

While only loading the packages being used is a good first step in reducing your total service worker file size, you can often reduce the total size even more by only loading the specific modules your code imports.

You can do this using JavaScript bundling tools like webpack or Rollup to build your own custom service worker file. This guide will outline the recommended way to use bundlers with Workbox.

Importing workbox code into your bundle

When using Workbox without a bundler, you import Workbox into your service worker file using importScripts():

// Import workbox-sw, which defines the global `workbox` object.
importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js');

// Then reference the workbox packages you need, which will dynamically
// call `importScripts()` to load the individual packages.
workbox.precaching.precacheAndRoute([...]);

When using a JavaScript bundler, you don't need (and actually shouldn't use) the workbox global or the workbox-sw module, as you can import the individual package files directly.

Here's how you'd import and run the above code using a bundler:

// Import just the `workbox-precaching` package.
import * as precaching from 'workbox-precaching';

precaching.precacheAndRoute([...]);

While the above code will work, it will import the entire workbox-precaching package, even though you're only using the precachingAndRoute() method. In many cases, your bundler can use tree-shaking techniques to remove unused exports, but depending on how your code is structured, it's not always 100% effective (and some bundlers are better at tree-shaking than others).

The best way to ensure your bundles are as small as possible is to not rely on tree shaking in the first place. If you only import the methods you need, then there won't be anything to tree shake.

Workbox makes it possible to do this because it defines all its public methods in their own file at the top level of the package directory. That means instead of referencing the package name in your import statements (e.g. from 'workbox-precaching'), you can reference a specific file:

// Import the `precacheAndRoute` file directly.
import {precacheAndRoute} from 'workbox-precaching/precacheAndRoute.mjs';

// Use the method.
precacheAndRoute([...]);

Determining import file paths

The definitive way to know what file to import is to look at the package source code, but to make it so you don't necessarily have to do that, we follow a few conventions that make it easy to know the path of the file:

  • Every workbox package is released as a separate npm package with the name converted to kebab-case (e.g. the workbox.backgroundSync object maps to the workbox-background-sync npm package).
  • Every property of each package object maps to a module file in the top-level package directly with the same name, plus the .mjs extension (e.g. workbox.backgroundSync.Queue is defined in workbox-background-sync/Queue.mjs).
  • Any .mjs file in the top-level directory of a workbox package is considered part of the public API and can be imported. Files in sub-directories should be considered implementation details and should not be relied upon.

Moving from importScripts to module imports

Here's a more complete example of taking code that uses importScripts() versus the same logic written for a bundler using import statements:

Using importScripts:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js');

workbox.routing.registerRoute(
  /\.(?:png|gif|jpg|jpeg|svg)$/,
  new workbox.strategies.CacheFirst({
    cacheName: 'images',
    plugins: [
      new workbox.expiration.Plugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
      }),
    ],
  }),
);

Using import via a bundler:

import {registerRoute} from 'workbox-routing/registerRoute.mjs';
import {CacheFirst} from 'workbox-strategies/CacheFirst.mjs';
import {Plugin as ExpirationPlugin} from 'workbox-expiration/Plugin.mjs';

registerRoute(
  /\.(?:png|gif|jpg|jpeg|svg)$/,
  new CacheFirst({
    cacheName: 'images',
    plugins: [
      new ExpirationPlugin({
        maxEntries: 60,
        maxAgeSeconds: 30 * 24 * 60 * 60, // 30 Days
      }),
    ],
  }),
);

Keeping dev-only code out of the bundle

One of the key developer features that Workbox offers is its logging, but when deploying Workbox code to production, you don't want users to pay the price of downloading that developer-focused code.

In the production builds of Workbox we release on our CDN, we've removed this code for you. But if you're making your own bundle, you'll need to do this yourself.

There are two things you should do to ensure dev-only code doesn't end up in a production bundle:

  • Replace instances of process.env.NODE_ENV in the source with the string 'production'.
  • Using a minifier that will remove falsy conditionals.

To understand how this works, consider this simplified example of what most of the dev-only code looks like in Workbox:

if (process.env.NODE_ENV !== 'production') {
  console.log('This is a dev-only log message!');
}

Using something like webpack's DefinePlugin or rollup-plugin-replace, you can replace all occurrences of process.env.NODE_ENV with the string 'production'. That means the above conditional will turn into the following block, and a minifier can safely discard the entire block (because it will never be true that 'production' !=== 'production').

if ('production' !== 'production') {
  console.log('This is a dev-only log message!');
}

The following examples show how you can set up minification and dev-only code removal in both webpack and Rollup:

Webpack

const webpack = require('webpack');
const Terser = require('terser-webpack-plugin');

module.exports = {
  plugins: [
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production'),
    }),
  ],
  optimization: {
    // Use terser instead of the default Uglify since service
    // worker code does not need to be transpiled to ES5.
    minimizer: [new Terser({
      // Ensure .mjs files get included.
      test: /\.m?js$/,
    })],
    // ...
  },
  // ...
};

Rollup

import replace from 'rollup-plugin-replace';
import {terser} from 'rollup-plugin-terser';

export default {
  plugins: [
    replace({
      'process.env.NODE_ENV': JSON.stringify('production'),
    }),
    terser(),
  ],
  // ...
}

Using bundlers with Workbox's existing build tools

workbox-build

The workbox-build package has a mode known as injectManifest, which replaces the string precacheAndRoute([]) in a file with a reference to the generated manifest.

The main thing to consider when using a bundler is that if you import the precacheAndRoute() method as a different name, the manifest injection will not work, and you'd have to manually set your own replace regular expression.

workbox-webpack-plugin

The primary purpose of workbox-webpack-plugin is to help developers create a precache manifest based on the files generated from their webpack build process.

If you also want to use webpack to generate your service worker file (as described in this article), beware that the workbox-webpack-plugin's injectManifest config accepts a swSrc option that it will update with your precache manifest.

This can be problematic if you're using the same webpack configuration to generate your service worker file, since it may not exist by the time the workbox-webpack-plugin code runs.

Code splitting and dynamic imports

Many bundlers today have some form of automatic code splitting based on dynamic imports, where the bundler automatically determines how to best split up your final JavaScript code so logic that might not be needed by the user right away can be loaded at a later time.

If you're using this feature with your bundler and you're also building a service worker, there are a few important gotchas to consider:

  • Most bundlers polyfill a version of the browsers native import() feature, and these polyfills are usually based on creating a <script> element, which will fail when run in a service worker.
  • Along the same lines, bundlers today (as of the most recent Workbox version) do not support sharing the same modules between the window and a service worker. This will not be possible until service worker supports dynamic imports.

Because of this, you should not use the dynamic import syntax in your service worker's JavaScript.

ES2015 syntax

One of the main reasons people use bundlers today is to also use transpilers to convert their modern JavaScript syntax to ES5 so it can be run in older browsers.

Keep in mind that every browser that supports service workers also supports most ES2015 features (the primary exception being import statements), so there's usually no need to transpile your code.

This means you can freely use features like classes and syntax like async/await when writing service worker code, which is fantastic as most service worker APIs are promise-based.