Ever noticed a webpage freezing during a heavy task? This happens because JavaScript runs on a single thread by default, causing a bad user experience. Users can't interact and have to wait until the task finishes. This problem can be solved using web workers. In this article, we will discuss what web workers are, why they are useful, and how to use them with a practical example by building an image compression application. Exciting, right? Let's get started.
What are Web Workers?
Web Workers let JavaScript run tasks in the background without blocking the main thread, which keeps your UI smooth and responsive. You can create them using the Web Workers API, which takes two parameters: url
and options
. Here is a simple example of creating a web worker.
const worker = new Worker('./worker.js', { type: 'module' });
Why use Web Workers?
As mentioned earlier, web workers run tasks in the background. Here are a few reasons to use them
Prevents page lag during heavy computations
Handles large data processing efficiently
Improves performance for complex web applications
How do they work?
The main thread creates a worker and gives it a task
The worker handles the task in the background
When finished, it sends the result back to the main thread
Alright, now we know what a web worker is, why to use them, and how they work. But that's not enough, right? So let's build the image compression application and see how to use web workers in practice.
Project setup
Create a Next.js project with TypeScript and Tailwind CSS
npx create-next-app@latest --typescript web-worker-with-example
cd web-worker-with-example
To compress images in the browser, we will use the @jsquash/web
npm library to encode and decode WebP images. This library is powered by WebAssembly, so let's install it.
npm install @jsquash/webp
Great, our project setup is complete. In the next section, we will create a worker script to manage image compression.
Creating worker script
A worker script is a JavaScript or TypeScript file that contains the code to handle worker message events.
Create an imageCompressionWorker.ts
file inside the src/worker
folder and add the following code.
/// <reference lib="webworker" />
const ctx = self as DedicatedWorkerGlobalScope;
import { decode, encode } from '@jsquash/webp';
ctx.onmessage = async (
event: MessageEvent<{
id: number;
imageFile: File;
options: { quality: number };
}>
) => {
// make sure the wasm is loaded
await import('@jsquash/webp');
const { imageFile, options, id } = event.data;
const fileBuffer = await imageFile.arrayBuffer();
try {
const imageData = await decode(fileBuffer);
const compressedBuffer = await encode(imageData, options);
const compressedBlob = new Blob([compressedBuffer], {
type: imageFile.type,
});
ctx.postMessage({ id, blob: compressedBlob });
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
ctx.postMessage({ id, error: message });
}
};
export {};
Here, we import the encode
and decode
methods from the @jsquash/webp
library and use the worker global scope, self
, to listen for messages from the main thread.
When a message arrives, we get the image file and options, then compress the image by first decoding and then encoding it with the quality option. Finally, we use postMessage
to send the compressed image blob back to the main thread. If there's an error, we handle it and send the error message back using postMessage
.
The worker script is ready. In the next section, we will build the Imagelist component, update the styles, update the page, and use the worker script to handle the compression.
Using the web worker
Before we start, let's update the global.css
file with the following content and remove the default styling.
@tailwind base;
@tailwind components;
@tailwind utilities;
Create a ImageList.tsx
in the src/components
folder and add the following code.
/* eslint-disable @next/next/no-img-element */
import React from 'react';
export type ImgData = {
id: number;
file: File;
status: 'compressing' | 'done' | 'error';
originalUrl: string;
compressedUrl?: string;
error?: string;
compressedSize?: number;
};
interface ImageListProps {
images: ImgData[];
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = 2;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const ImageList: React.FC<ImageListProps> = ({ images }) => {
return (
<div className="mt-4">
<h2 className="text-xl font-semibold mb-2">Image List</h2>
<div className="space-y-4">
{images.map((img) => (
<div
key={img.id}
className="flex flex-col md:flex-row items-center border p-4 rounded"
>
<div className="flex-1 flex flex-col items-center">
<p className="font-bold mb-2">Original</p>
<img
src={img.originalUrl}
alt="Original"
className="w-32 h-32 object-cover rounded border mb-2"
/>
<p className="text-sm">Size: {formatBytes(img.file.size)}</p>
</div>
{img.status === 'done' && img.compressedUrl ? (
<div className="flex-1 flex flex-col items-center mt-4 md:mt-0">
<p className="font-bold mb-2">Compressed</p>
<img
src={img.compressedUrl}
alt="Compressed"
className="w-32 h-32 object-cover rounded border mb-2"
/>
<p className="text-sm">
Size:{' '}
{img.compressedSize ? formatBytes(img.compressedSize) : 'N/A'}
</p>
<a
href={img.compressedUrl}
download={`${img.file.name.replace(
/\.[^/.]+$/,
''
)}-compressed.webp`}
className="mt-2 inline-block px-3 py-1 bg-blue-500 text-white rounded"
>
Download
</a>
</div>
) : img.status === 'compressing' ? (
<div className="flex-1 flex flex-col items-center mt-4 md:mt-0">
<p className="font-bold">Compressing...</p>
</div>
) : img.status === 'error' ? (
<div className="flex-1 flex flex-col items-center mt-4 md:mt-0">
<p className="font-bold text-red-500">Error in compression</p>
</div>
) : null}
</div>
))}
</div>
</div>
);
};
export default ImageList;
The ImageList component takes one prop, images
, which is a list of ImgData
. It then displays the original and compressed images, showing their size and providing a download option for the compressed images.
Next, update the app/page.tsx
with the following code, and let's go through the parts together.
'use client';
import { useState, useRef, useEffect } from 'react';
import ImageList, { ImgData } from '../components/ImageList';
export default function Home() {
const [images, setImages] = useState<ImgData[]>([]);
const [text, setText] = useState('');
const workerRef = useRef<Worker | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || !workerRef.current) return;
const filesArray = Array.from(files);
filesArray.forEach((file, index) => {
const id = Date.now() + index;
const originalUrl = URL.createObjectURL(file);
setImages((prev) => [
...prev,
{ id, file, status: 'compressing', originalUrl },
]);
// Send the file with its id to the worker.
workerRef.current!.postMessage({
id,
imageFile: file,
options: { quality: 75 },
});
});
};
// Initialize the worker once when the component mounts.
useEffect(() => {
const worker = new Worker(
new URL('../worker/imageCompressionWorker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current = worker;
// Listen for messages from the worker.
worker.onmessage = (
event: MessageEvent<{ id: number; blob?: Blob; error?: string }>
) => {
const { id, blob: compressedBlob, error } = event.data;
setImages((prev) =>
prev.map((img) => {
if (img.id === id) {
if (error) return { ...img, status: 'error', error };
const compressedSize = compressedBlob!.size;
const compressedUrl = URL.createObjectURL(compressedBlob!);
return { ...img, status: 'done', compressedUrl, compressedSize };
}
return img;
})
);
};
return () => {
worker.terminate();
};
}, []);
return (
<div className="min-h-screen p-8">
<h1 className="text-2xl font-bold text-center mb-4">
Image Compression with Web Workers
</h1>
<div className="rounded shadow p-4 mb-4 flex flex-col gap-2">
<p className="text-sm">
While images are compressing, you can interact with the textarea below
and observe the text being typed and UI is not frozen.
</p>
<p className="text-sm">
Even you can open the dev tools and then open the performance tab and
see the INP(Interaction to Next Paint) is very low.
</p>
<textarea
className="w-full h-32 border rounded p-2 text-black"
placeholder="Type here while images are compressing..."
value={text}
onChange={(e) => setText(e.target.value)}
></textarea>
</div>
<div className="rounded shadow p-4">
<input
type="file"
multiple
accept="image/webp"
onChange={handleFileChange}
/>
<ImageList images={images} />
</div>
</div>
);
}
First, we import the hooks and the ImageList component, along with ImgData for type.
import { useState, useRef, useEffect } from 'react';
import ImageList, { ImgData } from '../components/ImageList';
Then, we create a ref to hold the worker instance because we don't want to create the worker repeatedly with each re-render. We also want to avoid re-rendering the component if the worker instance changes.
const workerRef = useRef<Worker | null>(null);
In the useEffect, we are initializing the worker instance by using the imageCompressionWorker.ts
worker script we created earlier.
We use the URL API with
import.meta.url
. This makes the path relative to the current script instead of the HTML page. This way, the bundler can safely optimize, like renaming, because otherwise, theworker.js
URL might point to a file not managed by the bundler, stopping it from making assumptions. Read more here.
Once the worker is initialized, we listen for messages from it. When we receive a message, we extract the id, blob, and error, then update the images state with the new values.
Finally, we clean up the worker when the component unmounts.
useEffect(() => {
const worker = new Worker(
new URL('../worker/imageCompressionWorker.ts', import.meta.url),
{ type: 'module' }
);
workerRef.current = worker;
// Listen for messages from the worker.
worker.onmessage = (
event: MessageEvent<{ id: number; blob?: Blob; error?: string }>
) => {
const { id, blob: compressedBlob, error } = event.data;
setImages((prev) =>
prev.map((img) => {
if (img.id === id) {
if (error) return { ...img, status: 'error', error };
const compressedSize = compressedBlob!.size;
const compressedUrl = URL.createObjectURL(compressedBlob!);
return { ...img, status: 'done', compressedUrl, compressedSize };
}
return img;
})
);
};
return () => {
worker.terminate();
};
}, []);
To manage image file uploads, we use the handleFileChange
method. This method listens for the onchange
event of the file input, processes the files, and sends them to the worker for compression.
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || !workerRef.current) return;
const filesArray = Array.from(files);
filesArray.forEach((file, index) => {
const id = Date.now() + index;
const originalUrl = URL.createObjectURL(file);
setImages((prev) => [
...prev,
{ id, file, status: 'compressing', originalUrl },
]);
// Send the file with its id to the worker.
workerRef.current!.postMessage({
id,
imageFile: file,
options: { quality: 75 },
});
});
};
Finally, render the elements the textarea, image input, and image list.
User Selects Images: The user picks images using the file input, which makes the component create object URLs and mark each image as "compressing."
Worker Communication: The component sends each image file (with options) to the Web Worker.
-
Parallel Processes:
- Text Area Interaction: At the same time, the user can type in the text area, showing that the UI is not blocked.
- Image Compression: The worker compresses the image in the background.
Completion: When compression is done, the worker sends the result back to the component, which updates the UI with the compressed image while the text area keeps working smoothly.
Great, everything is set up. In the next section, we will run the application and see how the web worker works.
Running the example
Open the terminal and run the command below, then go to http://localhost:3000/.
npm run dev
Try the live demo here: https://web-worker-with-example.vercel.app/
Conclusion
Web workers are a great tool to improve application performance. By using Web Workers, you ensure faster, smoother, and more responsive applications. However, they should not be overused and should only be used when necessary. Also, check browser support, which is currently about 98% globally. You can check it here.
That's all for this topic. Thank you for reading! If you found this article helpful, please consider liking, commenting, and sharing it with others.
Top comments (2)
This is a pretty detailed guide. Seems good to me, nice work!
Thanks for reading and glad you found it useful