Mazzarolo MatteoMazzarolo Matteo

Loading web workers using Webpack 5

By Mazzarolo Matteo


Just wanted to share a few notes around the currently available options for loading web workers using webpack 5.

Web Workers overview

Web workers allow you to push work outside of main execution thread of JavaScript, making them convenient for lengthy computations and background work.

Web workers are delivered as scripts that are loaded asynchronously using the Web Worker API.

A worker is an object created using a constructor (e.g., Worker()) that runs a named JavaScript file.

To create a new worker, all you need to do is call the Worker() constructor, specifying the URI of a script to execute:

// Assuming we're in a JavaScript script that runs in your main thread and that
// the worker script is available at yourdomain.com/worker.js, this will take
// care of spawning a new worker:
const myWorker = new Worker("worker.js");
// Assuming we're in a JavaScript script that runs in your main thread and that
// the worker script is available at yourdomain.com/worker.js, this will take
// care of spawning a new worker:
const myWorker = new Worker("worker.js");

Since they're loaded as separate scripts, web workers can't be "bundled" within the code that runs in the main thread. This means that if you're using a module bundler to bundle your code (e.g., Webpack, Rollup) you'll have to maintain two separate build processes, which can be pretty annoying.

The good news is that, if you're using webpack, there are a couple of tools you can use to simplify the loading process of web workers.

Web Workers in webpack 5

Since webpack 5, web workers are first-class citizens, and you can use a specific syntax to let webpack automatically handle the creation of two separate bundles.
To do so, you must use the import.meta object (an object that exposes context-specific metadata) to provide the module URL to the Worker() constructor:

const myWorker = new Worker(new URL("./worker.js", import.meta.url));
const myWorker = new Worker(new URL("./worker.js", import.meta.url));

Note that the import.meta.url construct is available only available in ESM. Worker in CommonJS syntax is not supported.

As of today, there's not much documentation around webpack 5's web worker supports. It indeed works pretty well for the most common use-cases and it's a future-proof way to load web worker, but, for now, if you're looking for a more flexible way to load web workers, you might want to take a look at worker-loader.

Webpack 5 and Worker Loader

worker-loader is the pre-webpack-5 way to load web workers, and its documentation highlights how it's not compatible with webpack 5 ("Worker Loader is a loader for webpack 4").
Still, in my experience, besides a few quirks, worker-loader can be used with webpack 5, and it offers several more customization options than webpack 5's built-in web worker support.

The most important ones are probably the support for inlining web workers as BLOB and specifying a custom publicPath.

Inlining web workers

Web workers are restricted by a same-origin policy, so if your webpack assets are not being served from the same origin as your application, their download may be blocked by your browser.

This scenario can commonly occur if you are serving the web worker from localhost during local development (e.g., with webpack-dev-server):

// If the origin of your application is available at a different origin than
// localhost:8080, you won't be able to load the following web worker:
const myWorker = new Worker(
  new URL("http://localhost:8080/worker.js");
);
// If the origin of your application is available at a different origin than
// localhost:8080, you won't be able to load the following web worker:
const myWorker = new Worker(
  new URL("http://localhost:8080/worker.js");
);

worker-loader solves the local development issue by allowing you to inlining the web worker as a BLOB (instead of pointing it to localhost) on development builds by specifying an inline: "fallback" option:

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        loader: "worker-loader",
        options: { inline: isDevelopment ? "fallback" : "no-fallback" },
      },
    ],
  },
};
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        loader: "worker-loader",
        options: { inline: isDevelopment ? "fallback" : "no-fallback" },
      },
    ],
  },
};

Setting a worker-specific publicPath

Another scenario where the same-origin policy might need some accommodations is if you're hosting your main bundle code on a static CDN.
In this case, you're probably going to set the publicPath of your webpack output to the CDN domain (e.g., https://my-static-cdn), so that all the assets will reference it in production. Unfortunately, this pattern doesn't work well when using web workers because you can't reference a web worker that is hosted on a CDN (because of the same-origin policy):

// Since the origin of the application (e.g., https://example.com) is different
// from the CDN one, you won't be able to load the following web worker:
const myWorker = new Worker(
  new URL("https://my-static-cdn/worker.js");
);
// Since the origin of the application (e.g., https://example.com) is different
// from the CDN one, you won't be able to load the following web worker:
const myWorker = new Worker(
  new URL("https://my-static-cdn/worker.js");
);

What's great about worker-loader, is that you can solve this issue by setting a worker-specific publicPath:

module.exports = {
  output: {
    // Set the publicPath of all assets generated by this webpack build to
    // https://my-static-cdn/.
    publicPath: "https://my-static-cdn/",
  },
  module: {
    rules: [
      {
        loader: "worker-loader",
        // Overrides the publicPath just for the web worker, marking it as
        // available on the same origin used by the app (notice that this is
        // a relative path).
        options: { publicPath: "/workers/" },
      },
    ],
  },
};
module.exports = {
  output: {
    // Set the publicPath of all assets generated by this webpack build to
    // https://my-static-cdn/.
    publicPath: "https://my-static-cdn/",
  },
  module: {
    rules: [
      {
        loader: "worker-loader",
        // Overrides the publicPath just for the web worker, marking it as
        // available on the same origin used by the app (notice that this is
        // a relative path).
        options: { publicPath: "/workers/" },
      },
    ],
  },
};

A note on setting worker-loader's publicPath with webpack 5

Webpack 5 introduced a mechanism to detect the publicPath that should be used automatically. Sadly, the new automatic detection seems to be incompatible with worker-loader's publicPath... but there are a couple of (hacky) ways you can solve this issue ;)

The first one is to by setting the publicPath on the fly.
Webpack 5 exposes a global variable called __webpack_public_path__ that allows you to do that.

// Updates the `publicPath` at runtime, overriding whatever was set in the
// webpack's `output` section.
__webpack_public_path__ = "/workers/";
 
const myWorker = new Worker(
  new URL("/workers/worker.js");
);
 
// Eventually, restore the `publicPath` to whatever was set in output.
__webpack_public_path__ = "https://my-static-cdn/";
// Updates the `publicPath` at runtime, overriding whatever was set in the
// webpack's `output` section.
__webpack_public_path__ = "/workers/";
 
const myWorker = new Worker(
  new URL("/workers/worker.js");
);
 
// Eventually, restore the `publicPath` to whatever was set in output.
__webpack_public_path__ = "https://my-static-cdn/";

I recommend using webpack's DefinePlugin to pass the public path as environment variables instead of hardcoding them in the source code.

The other (even more hacky) option is to apply the following patch to worker-loader (using patch-package, for example):

# worker-loader+3.0.8.patch
# Compatible only with worker-loader 3.0.8.
diff --git a/node_modules/worker-loader/dist/utils.js b/node_modules/worker-loader/dist/utils.js
index 5910165..2f2d16e 100644
--- a/node_modules/worker-loader/dist/utils.js
+++ b/node_modules/worker-loader/dist/utils.js
@@ -63,12 +63,14 @@ function workerGenerator(loaderContext, workerFilename, workerSource, options) {
   const esModule = typeof options.esModule !== "undefined" ? options.esModule : true;
   const fnName = `${workerConstructor}_fn`;
 
+  const publicPath = options.publicPath ? `"${options.publicPath}"` : '__webpack_public_path__';
+
   if (options.inline) {
     const InlineWorkerPath = (0, _loaderUtils.stringifyRequest)(loaderContext, `!!${require.resolve("./runtime/inline.js")}`);
     let fallbackWorkerPath;
 
     if (options.inline === "fallback") {
-      fallbackWorkerPath = `__webpack_public_path__ + ${JSON.stringify(workerFilename)}`;
+      fallbackWorkerPath = `${publicPath} + ${JSON.stringify(workerFilename)}`;
     }
 
     return `
@@ -77,7 +79,7 @@ ${esModule ? `import worker from ${InlineWorkerPath};` : `var worker = require($
 ${esModule ? "export default" : "module.exports ="} function ${fnName}() {\n  return worker(${JSON.stringify(workerSource)}, ${JSON.stringify(workerConstructor)}, ${JSON.stringify(workerOptions)}, ${fallbackWorkerPath});\n}\n`;
   }
 
-  return `${esModule ? "export default" : "module.exports ="} function ${fnName}() {\n  return new ${workerConstructor}(__webpack_public_path__ + ${JSON.stringify(workerFilename)}${workerOptions ? `, ${JSON.stringify(workerOptions)}` : ""});\n}\n`;
+  return `${esModule ? "export default" : "module.exports ="} function ${fnName}() {\n  return new ${workerConstructor}(${publicPath} + ${JSON.stringify(workerFilename)}${workerOptions ? `, ${JSON.stringify(workerOptions)}` : ""});\n}\n`;
 } // Matches only the last occurrence of sourceMappingURL
# worker-loader+3.0.8.patch
# Compatible only with worker-loader 3.0.8.
diff --git a/node_modules/worker-loader/dist/utils.js b/node_modules/worker-loader/dist/utils.js
index 5910165..2f2d16e 100644
--- a/node_modules/worker-loader/dist/utils.js
+++ b/node_modules/worker-loader/dist/utils.js
@@ -63,12 +63,14 @@ function workerGenerator(loaderContext, workerFilename, workerSource, options) {
   const esModule = typeof options.esModule !== "undefined" ? options.esModule : true;
   const fnName = `${workerConstructor}_fn`;
 
+  const publicPath = options.publicPath ? `"${options.publicPath}"` : '__webpack_public_path__';
+
   if (options.inline) {
     const InlineWorkerPath = (0, _loaderUtils.stringifyRequest)(loaderContext, `!!${require.resolve("./runtime/inline.js")}`);
     let fallbackWorkerPath;
 
     if (options.inline === "fallback") {
-      fallbackWorkerPath = `__webpack_public_path__ + ${JSON.stringify(workerFilename)}`;
+      fallbackWorkerPath = `${publicPath} + ${JSON.stringify(workerFilename)}`;
     }
 
     return `
@@ -77,7 +79,7 @@ ${esModule ? `import worker from ${InlineWorkerPath};` : `var worker = require($
 ${esModule ? "export default" : "module.exports ="} function ${fnName}() {\n  return worker(${JSON.stringify(workerSource)}, ${JSON.stringify(workerConstructor)}, ${JSON.stringify(workerOptions)}, ${fallbackWorkerPath});\n}\n`;
   }
 
-  return `${esModule ? "export default" : "module.exports ="} function ${fnName}() {\n  return new ${workerConstructor}(__webpack_public_path__ + ${JSON.stringify(workerFilename)}${workerOptions ? `, ${JSON.stringify(workerOptions)}` : ""});\n}\n`;
+  return `${esModule ? "export default" : "module.exports ="} function ${fnName}() {\n  return new ${workerConstructor}(${publicPath} + ${JSON.stringify(workerFilename)}${workerOptions ? `, ${JSON.stringify(workerOptions)}` : ""});\n}\n`;
 } // Matches only the last occurrence of sourceMappingURL

For more info, check this GitHub issue.