Web Workers and Vite

06 January 2025

Updated: 06 January 2025

Web workers provide a mechanism for running code outside of the main thread using Javascript. They are currently supported in the browser as well as in Node.js, but for the purpose of this post we’ll look at how to use them from within the browser

Vanilla

Web workers can be used directly from the browser using some script tags. For the sake of example, we’ll use the following HTML file with it’s related script tags:

web-workers/plain/index.html
1
<!DOCTYPE html>
2
<html lang="en">
3
<head>
4
<meta charset="UTF-8" />
5
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
6
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
<title>Web Workers</title>
8
9
<!-- script for launching web workers -->
10
<script type="module" src="./worker-launcher.js"></script>
11
12
<!-- script to register service worker -->
13
<script type="module" src="./register-service-worker.js"></script>
14
</head>
15
<body>
16
<h1>Images that will br proxied to Service Worker</h1>
17
<div>
18
<img src="placeholder?height=400&width=500" />
19
<img src="placeholder?height=200&width=500" />
20
</div>
21
</body>
22
</html>

In the above, we can see three scripts referenced, we’ll take a look at these as we talk about the mechanisms for using web workers

Workers

Web workers are simply Javascript modules that are loaded by some other code dynamically as a Worker

If we assume we have some worker called simple-worker.js in the same directory of our page, we can load it using the following code:

1
const worker = new Worker("./simple-worker.js");

Upon doing this, the simple-worker.js script will be loaded and executed.

Now, the real value of workers comes from the ability to get data from the code that launches it, this is done using worker.postMessage, this allows us to send any data we want to the worker we have defined, so we can do this in our script, an example of this can be seen below:

web-workers/plain/simple-worker-launcher.js
1
const worker = new Worker("./simple-worker.js");
2
console.log({ worker });
3
4
worker.postMessage("hello there");
5
6
setTimeout(() => {
7
worker.terminate();
8
worker.postMessage("hello again");
9
}, 1000);

On the other side of this conversation we have the actual worker script/module. This actually looks really simple:

web-workers/plain/simple-worker.js
1
console.log("launched simple worker");
2
3
self.addEventListener("message", (e) => console.log("got event", e.data));

This file uses self.addEventListener to listen to messsages sent to the worker, when a message is received, the worker can decide what to do with it - what’s important to know is that this handler does not block the main thread so it can be as intensive as we want

The worker can also be more complex, for example we can see in the below message that the worker receives a message via self.addEventListener, after doing some intensive processing, it calls self.postMessage to basically respond to the parent

The parent can then listen to these messages with worker.addEventListener

web-workers/plain/worker-launcher.js
1
const worker = new Worker("./worker.js");
2
console.log({ worker });
3
4
worker.postMessage("hello there");
5
6
worker.addEventListener("message", (e) =>
7
console.log("message from worker", e.data)
8
);

And we can see the worker that sends messages as well:

web-workers/plain/worker.js
1
// in the worker state `self` is an instance of `DedicatedWorkerGlobalScope` which is like `window`
2
// `window` is also not defined in this scope
3
console.log({ self, typeofWindow: typeof window });
4
5
const doIntensiveThing = () => {
6
console.log("started intensive thing");
7
const start = Date.now();
8
const end = start + 3000;
9
10
// do nothing, just block the tread for 10 seconds
11
while (Date.now() < end) {}
12
};
13
14
// some events we can also listen for are on the `self` object
15
self.addEventListener("message", (ev) => {
16
// messages passed from the service worker will arrive here
17
console.log({ dataToWorker: ev.data });
18
19
// if the message is the word `slow` then we will do some intensive thing
20
if (ev.data === "slow") {
21
self.postMessage("start processing slow thing");
22
doIntensiveThing();
23
self.postMessage("done processing slow thing");
24
} else {
25
self.postMessage("done processing fast thing");
26
}
27
});

ServiceWorkers

ServiceWorkers are a special kind of worker that acts as a proxy between the application and network and can interact with network requests even when the device is not connected to the server

Service workers have different events that may be triggered, commonly is the installed, activated, and fetch event among others, we add listners for these events the same as we did in the above

web-workers/plain/service-worker.js
1
self.addEventListener("install", () => console.log("installed"));
2
3
self.addEventListener("activate", () => console.log("activated"));
4
5
self.addEventListener("fetch", (event) => {
6
console.log(event.request.url);
7
8
if (event.request.url.includes("placeholder")) {
9
const url = new URL(event.request.url);
10
11
const height = url.searchParams.get("height") || 200;
12
const width = url.searchParams.get("width") || 500;
13
14
console.log("image requested");
15
event.respondWith(
16
fetch(
17
`https://placehold.co/${height}x${width}?text=This image does not exist`
18
)
19
);
20
}
21
});

In the above file, we intercept requests to the placeholder url and proxy it by making a new request to https://placeholder.co, we could do anything here though, even responding with our own data if we want

ServiceWorkers are special though since an application can only have a single ServiceWorker. Creating a ServiceWorker is done using navigator.serviceWorker.register and not using new Worker, an example of service worker registration can be seen below:

web-workers/plain/register-service-worker.js
1
navigator.serviceWorker
2
.register("service-worker.js")
3
.then(() => console.log("registered service worker"));

Vite

In practice we’re rarely using some random Javascript files though and often have more complex scenarios with external dependencies or even stuff like Typescript involved, in this case we need to consider things like bundling as well as somehow resolving the name of the file we need to load. Thankfully modern bundlers like Vite have a solution for us

In the case of Vite specifically, it provides us with an API that we can use while importing files that Vite uses to infer that a file should be treated as a worker. Vite does this by using the ?worker at the end of the import path

Assume we have some Vite-based project that uses a worker such as the one below. That worker also imports some code from another file we have and we expect it all to be bundled correctly:

web-workers/vite/src/canvas.worker.ts
1
import { animate } from "./canvas"
2
3
export interface InvokeParams {
4
canvas: OffscreenCanvas
5
}
6
7
// setup if in worker
8
addEventListener('message', e => run(e.data))
9
10
function run({ canvas }: InvokeParams) {
11
animate(canvas)
12
}

We can see that we have a run function that is called when we receive the message as before, we also have a type specified for the data we expect to receive

When we import something using ?worker Vite creates a constructor for us that will initialize the worker without us having to manually pass the path to the file, so a consumer of this file can now use the worker a bit like this:

1
import CanvasWorker from './canvas.worker?worker'
2
import type { InvokeParams } from './canvas.worker'
3
4
const worker = new CanvasWorker()
5
6
worker.postMessage({ canvas: offscreen } satisfies InvokeParams, [offscreen])

Note that we also use the type import above so we can keep the type definition of the data our worker expects in the same palce as the worker

A more full example of this can be seen below:

web-workers/vite/src/canvas.worker.ts
1
import { animate } from "./canvas"
2
3
export interface InvokeParams {
4
canvas: OffscreenCanvas
5
}
6
7
// setup if in worker
8
addEventListener('message', e => run(e.data))
9
10
function run({ canvas }: InvokeParams) {
11
animate(canvas)
12
}

Something that’s also interesting to note is the second parameter of postMessage, this is an array of TransferrableObjects that are specific objects that the browser allows us to share between the main thread and worker threads. In the above example we’re using a OffscreenCanvas but this can be any of the objects that are defined as TransferrableObjects

Conclusion

Overall, web workers are pretty fun to play around with and really increase the options available to us when working to make applications more performant. Modern tools like Vite also make using them pretty easy

There’s a lot that you can do with workers and I’ve simply provided you with a small overview of the functionality, looking at the MDN documentation is always a good place to go to learn more

References