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...