Process Image Batches in the Browser Without Freezing the UI

javatuts1 pts0 comments

Process Huge Image Batches in the Browser Without Freezing the UI | Free Image ToolsMenuImage ToolsPDF ToolsConversionPluginsBlog

Processing one image in the browser is easy.

Processing 500 high-resolution images is a different problem.

Suppose someone selects a folder containing several gigabytes of photos. We want to resize every<br>image, compress it to WebP, show progress, and let the user download the results.

We could send everything to a server.

But for many tools, that would be wasteful. The browser already has the original files, image<br>decoders, Canvas APIs, multiple CPU cores, and enough storage options to build the entire pipeline<br>locally.

The problem is keeping the page responsive while all that work happens.

If we decode and resize hundreds of images on the main thread, the UI competes with the processing<br>loop:

Main thread

React / UI<br>decode image<br>resize<br>encode<br>decode next image<br>resize<br>encode<br>Buttons become sluggish. Progress indicators stop moving smoothly. On a large enough batch, the tab<br>may run out of memory.

A better architecture separates the work:

Main thread<br>| files<br>Worker pool<br>| decode + resize + encode<br>Processed Blob<br>Main thread<br>That is what we’ll build.

The important part is not one clever Canvas call. It is controlling how files move through memory.

The Problem With “Gigabytes”

A 10 MB JPEG does not necessarily consume 10 MB while you’re processing it.

Once decoded, the browser works with pixels.

A 6000 × 4000 image contains:

24,000,000 pixels<br>At roughly four bytes per RGBA pixel, one decoded buffer can require around:

24,000,000 × 4<br>≈ 96 MB<br>Now imagine decoding 20 of those at once.

This is why the first rule of large browser-side image processing is simple:

Do not load the entire batch into decoded memory at once.

The browser can handle a batch whose source files total several gigabytes, but only if we treat it<br>as a stream of jobs.

We want:

500 source files<br>small queue<br>2-4 active jobs<br>finished output<br>not:

500 files<br>decode everything<br>hope the tab survives<br>What We Are Building

Our example will accept multiple images and convert them to WebP.

Each image will be:

decoded inside a worker

resized if necessary

drawn to an OffscreenCanvas

encoded as WebP

returned to the main thread as a Blob

The main thread will only coordinate the work and update the UI.

A simple project can look like this:

src/<br>├── image-worker.ts<br>├── worker-pool.ts<br>├── process-images.ts<br>└── main.ts<br>We’ll keep the processing code independent of React so it can be reused in any frontend.

Step 1: Start With One Image

Before adding workers, let’s define what processing means.

We want to receive a file and produce a smaller WebP image.

Conceptually:

File<br>decode<br>ImageBitmap<br>Canvas<br>resize<br>WebP Blob<br>In modern browsers, createImageBitmap() gives us a convenient way to decode an image:

const bitmap = await createImageBitmap(file);<br>Once decoded, we know its dimensions:

console.log(bitmap.width);<br>console.log(bitmap.height);<br>Now we can calculate the output size.

Step 2: Calculate the New Dimensions

We do not want to stretch images or change their aspect ratio.

Let’s create a helper:

function fitImage(width: number, height: number, maxWidth: number, maxHeight: number) {<br>const scale = Math.min(maxWidth / width, maxHeight / height, 1);

return {<br>width: Math.round(width * scale),<br>height: Math.round(height * scale),<br>};<br>The final 1 is important.

It prevents smaller images from being enlarged.

For example:

fitImage(4000, 3000, 1600, 1600);<br>returns approximately:

width: 1600,<br>height: 1200,<br>while:

fitImage(800, 600, 1600, 1600);<br>keeps the original dimensions.

Step 3: Move Processing Into a Web Worker

A Web Worker runs JavaScript away from the main UI thread.

Create image-worker.ts:

type ProcessMessage = {<br>id: number;<br>file: File;<br>maxWidth: number;<br>maxHeight: number;<br>quality: number;<br>};

self.onmessage = async (event: MessageEventProcessMessage>) => {<br>const { id, file, maxWidth, maxHeight, quality } = event.data;

try {<br>const blob = await processImage(file, maxWidth, maxHeight, quality);

self.postMessage({<br>id,<br>ok: true,<br>blob,<br>});<br>} catch (error) {<br>self.postMessage({<br>id,<br>ok: false,<br>error: error instanceof Error ? error.message : 'Image processing failed',<br>});<br>};<br>The worker receives one job and returns one result.

Now we need processImage().

Step 4: Decode With createImageBitmap()

Inside the worker:

async function processImage(file: File, maxWidth: number, maxHeight: number, quality: number) {<br>const bitmap = await createImageBitmap(file);

try {<br>const size = fitImage(bitmap.width, bitmap.height, maxWidth, maxHeight);

// Processing continues here...<br>} finally {<br>bitmap.close();<br>Notice the finally.

ImageBitmap has a close() method. Once we have finished using its pixel data, we should release<br>those resources instead of waiting around for cleanup later.

That matters when the function runs hundreds of times.

Step 5: Resize With OffscreenCanvas

A normal belongs to the DOM.

Workers do not...

image number processing file worker decode

Related Articles