Web development has evolved at a tremendous pace, and part of that evolution includes how we structure our applications for optimal performance. One tool that has been available but under-utilized is the Web Worker API.
For years, it has provided a means to run JavaScript in background threads, freeing up the main thread for user interactions and ensuring your application remains responsive.
However, using Web Workers in JavaScript traditionally involves some degree of complexity. Let's take a closer look at these challenges and a promising new approach that simplifies the process.
When using Web Workers, developers must create a separate JavaScript file containing the logic to be run on the worker thread. This worker script is then instantiated with the new Worker()
constructor.
const myWorker = new Worker('worker.js');
Communication between your main thread and the worker thread is done manually, using postMessage()
to send data and onmessage
to listen for responses.
<html lang="en">
<body>
<h1>Web Workers basic example</h1>
<div class="controls" tabindex="0">
<form>
<div>
<label for="number1">Multiply number 1: </label>
<input type="text" id="number1" value="0" />
</div>
<div>
<label for="number2">Multiply number 2: </label>
<input type="text" id="number2" value="0" />
</div>
</form>
<p class="result">Result: 0</p>
</div>
<script>
const first = document.querySelector('#number1');
const second = document.querySelector('#number2');
const result = document.querySelector('.result');
if (window.Worker) {
const myWorker = new Worker("worker.js");
first.onchange = function() {
myWorker.postMessage([first.value, second.value]);
console.log('Message posted to worker');
}
second.onchange = function() {
myWorker.postMessage([first.value, second.value]);
console.log('Message posted to worker');
}
myWorker.onmessage = function(e) {
result.textContent = e.data;
console.log('Message received from worker');
}
} else {
console.log('Your browser doesn\'t support web workers.');
}
</script>
</body>
</html>
// worker.js
onmessage = function(e) {
console.log('Worker: Message received from main script');
const result = e.data[0] * e.data[1];
if (isNaN(result)) {
postMessage('Please write two numbers');
} else {
const workerResult = 'Result: ' + result;
console.log('Worker: Posting message back to main script');
postMessage(workerResult);
}
}
While this approach works, it involves a lot of boilerplate code and manual handling of communication between threads. This complexity often discourages developers from utilizing the full potential of Web Workers.
Swift, the popular language for iOS development, offers a streamlined approach to multi-threading with the Dispatch Queue. Developers can write inline code that's executed on a worker thread, a feature JavaScript developers have longed for.
This type of code is much cleaner to write and read:
DispatchQueue.global(qos: .userInteractive.async {
// this does something in another thread
}
However, libraries such as Comlink have attempted to bridge this gap, allowing developers to run code in a Web Worker while keeping the main script clean and readable. But even with these libraries, the logic still needs to be written in a separate file:
// main.js
import * as Comlink from "https://unpkg.com/comlink/dist/esm/comlink.mjs";
async function init() {
const worker = new Worker("worker.js");
// WebWorkers use `postMessage` and therefore work with Comlink.
const obj = Comlink.wrap(worker);
alert(`Counter: ${await obj.counter}`);
await obj.inc();
alert(`Counter: ${await obj.counter}`);
}
init();
// worker.js
importScripts("https://unpkg.com/comlink/dist/umd/comlink.js");
// importScripts("../../../dist/umd/comlink.js");
const obj = {
counter: 0,
inc() {
this.counter++;
},
};
Comlink.expose(obj);
Qwik’s new experimental worker$()
function (@builder.io/qwik-worker
) automatically allows developers to execute code in a Web Worker from JSX.
You can find the working code examples in this GitHub repo.
Consider the code:
<button
class="border-2 p-2 border-black"
onClick$={worker$(() => {
console.log('runs on the worker thread')
})}
>
WEB WORKER
</button>
By wrapping your function in worker$()
, the function is automatically run in a separate Web Worker, which frees the main thread from heavy computations. This approach eliminates the need for separate worker files and manual message handling, which significantly reduces the boilerplate code associated with Web Workers.
Consider an image processing operation like applying a Median filter to reduce noise. Such operations are CPU-intensive and would block the main thread if executed directly in JavaScript and freeze your application until the operation completes.
Even a little CSS animation gets frozen when something blocks your browser's main thread, as you might notice from the blue box below:
And this is the code that runs:
import { component$, useSignal } from '@builder.io/qwik';
import { medianFilter } from './processor';
import { UPNG } from './png';
// This could be any image
const IMG = 'http://localhost:5173/noisyimg.png';
export default component$(() => {
const imageSrc = useSignal<string>();
return (
<>
<button
class="border-2 p-2 border-black"
onClick$={async () => {
imageSrc.value = await filterImage(IMG);
}}
>
Filter
</button>
<button
class="border-2 p-2 border-black"
onClick$={async () => {
imageSrc.value = undefined;
}}
>
Reset
</button>
<div class="walkabout-old-school"></div>
<div class="flex">
<img src={IMG} width="350" height="350" />
<img src={imageSrc.value} width="350" height="350" />
</div>
</>
);
});
export const filterImage = async (src: string) => {
const res = await fetch(src);
const data = await res.arrayBuffer();
console.time('decode');
const png = UPNG.decode(data) as any;
const output = medianFilter(png.data, png.width);
const newPNG = (UPNG as any).encodeLL([output],png.width,png.height,1,0,8);
const blob = new Blob([newPNG], { type: 'image/png' });
console.timeEnd('decode');
return URL.createObjectURL(blob);
};
The filterImage
function does the following:
- The PNG is decoded using the UPNG library. The result is an array of pixels, each pixel containing 4 bytes: R, G, B, A.
- The pixels are transformed into a 2D array of RGBA values.
- The median filter is applied to the 2D array.
- The result is encoded back into a PNG.
With Qwik's worker$()
function, you can offload this operation to a Web Worker to ensure your application remains responsive. All you need to do is to wrap the median
filter function in worker$()
, so it automatically runs in a separate Web Worker:
export const filterImage = worker$(async (src: string) => {
const res = await fetch(src);
const data = await res.arrayBuffer();
console.time('decode');
const png = UPNG.decode(data) as any;
const output = medianFilter(png.data, png.width);
const newPNG = (UPNG as any).encodeLL([output],png.width,png.height,1,0,8);
const blob = new Blob([newPNG], {type: 'image/png'});
console.timeEnd('decode');
return URL.createObjectURL(blob);
});
As a result, the main thread remains free to handle user interactions, and your application stays responsive, even during heavy image processing. Notice how after this change, the blue block does not lag:
But Qwik doesn't stop at Web Workers. With server$
(which is built into Qwik City) you can execute your code on the server, which provides an ideal place to access the database or perform server-specific actions:
import { server$ } from '@builder.io/qwik-city';
const serverGreeter = server$((firstName, lastName) => {
// Server-side operations here
// Will execute on the server
});
The function wrapped by server$
is marked to always execute on the server. It can accept any number of arguments and return any value that can be serialized by Qwik. This includes primitives, objects, arrays, BigInts, JSX nodes, and even Promises.
Moreover, server$
can also return a stream of data using an async generator. This feature is particularly useful for streaming data from the server to the client. For example, this code will stream in a number to the client every 200ms:
import { component$, useSignal } from '@builder.io/qwik';
import { server$ } from '@builder.io/qwik-city';
const stream = server$(async function* () {
for (let i = 0; i < 10; i++) {
yield i;
// will be logged in server
console.log(i);
await new Promise((resolve) => setTimeout(resolve, 200));
}
});
export default component$(() => {
const messageFromServer = useSignal<string>('');
return (
<div>
<button
class="border-2 p-2 border-black"
onClick$={async () => {
const response = await stream();
for await (const i of response) {
messageFromServer.value += ` ${i}`;
}
}}
>
Stream from server to client
</button>
<div>{messageFromServer.value}</div>
</div>
);
});
Here it is in action:
With Qwik, you can change your code from running in a Web Worker to running on the server by merely changing worker$
to server$
.
const ServerButton = () => {
return (
<button onClick={server$(expensiveOperation)}>Run on Server</button>
);
}
This action results in the execution of your code on the server. The client remains idle during the operation, improving the overall performance of your application.
Leveraging Web Workers and server-side execution, Qwik offers a powerful way to build fast, responsive web applications. This approach minimizes boilerplate code and improves the maintainability of your codebase, all while ensuring your application remains responsive even during intensive computations.
To learn more about Qwik and its capabilities, we recommend checking out these blog posts on code extraction and the journey to Qwik v1.
Happy coding!
Introducing Visual Copilot: convert Figma designs to high quality code in a single click.