A few months ago, I needed to convert 400 product images from JPG to WebP for a Shopify store.
I tried the usual online converters. Every single one of them made me upload my files to a server, wait in a queue, and then download a ZIP. Some put watermarks on the free tier. Others capped me at 10 images unless I paid.
For 400 images, that workflow was painful. But the part that really bothered me was the upload. These were product photos. I did not want them sitting on someone else's server, even temporarily.
So I built BatchSet, a bulk image converter that runs entirely in your browser. No upload. No signup. No watermark. You drop your images, they convert locally, and you download the ZIP.
This post is about how the browser-side pipeline works, where it breaks down, and why "client-side only" is both more powerful and more limited than you might think.
The Core Pipeline
The browser can do a surprising amount of image processing today. The core loop in BatchSet looks like this:
User drops files
|
FileReader reads as ArrayBuffer
|
createImageBitmap decodes in a Web Worker
|
OffscreenCanvas draws + encodes to target format
|
JSZip packages everything
|
Auto-download ZIP
Let me break down the key parts.
1. createImageBitmap: the decoder
Most people still use new Image() or canvas.drawImage() with a source image. That works, but it runs on the main thread and blocks the UI.
createImageBitmap is different. It decodes the image off the main thread and returns an ImageBitmap you can draw to any canvas without re-decoding.
// Inside a Web Worker
const bitmap = await createImageBitmap(file);
This is fast. For a typical 2MB product photo, decoding takes 20-40ms. The real win is that it does not block the UI thread, so the progress bar stays smooth even when processing hundreds of images.
2. OffscreenCanvas: the encoder
Once you have the decoded bitmap, you need to encode it to the target format. For WebP, JPG, or PNG output, you draw the bitmap to an OffscreenCanvas and call convertToBlob.
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0, width, height);
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality: 0.80
});
OffscreenCanvas works inside a Web Worker, so the heavy encoding work (especially for large images or high quality) does not freeze the tab.
3. Web Workers: parallel processing
The real gain comes from running multiple workers in parallel. BatchSet spawns one worker per logical CPU core using navigator.hardwareConcurrency.
const workers = [];
const cores = navigator.hardwareConcurrency || 4;
for (let i = 0; i < cores; i++) {
workers.push(new Worker('/worker.js'));
}
Images are distributed round-robin across workers. On a modern laptop with 8 cores, 8 images are being decoded and encoded simultaneously. A batch of 200 images that takes about 60 seconds sequentially finishes in under 10 on my machine.
4. JSZip: packaging without memory explosions
The tricky part with bulk conversion is not the processing. It is the memory.
If you hold every converted image in memory before creating the ZIP, a 500-image batch will crash the tab. Browsers cap how much memory a single tab can use, usually somewhere in the 2-4GB range depending on the device.
BatchSet streams images into JSZip incrementally using Blobs rather than base64 strings. Base64 inflates size by roughly 33%. Blobs do not.
Even then, for very large batches (500+ high-res images), the browser will eventually hit the wall. The honest fix is to split into smaller batches, or tell the user their device is the bottleneck.
What the Browser Cannot Do (Yet)
The client-side pipeline handles JPG, PNG, WebP, GIF, BMP, and SVG well. Some formats it simply cannot:
- HEIC — iPhone photos. No native browser encoder. We use Sharp on the server for these.
- TIFF — no browser support at all. Server-side only.
- Exact target file size — browser encoding APIs do not let you say "make this exactly 150KB." That needs iterative re-encoding, which is cheaper to do server-side with Sharp.
- Watermarking with complex fonts and layouts — possible in canvas, but messy. Server-side is more reliable.
So the architecture is not "browser only." It is "browser by default, server for the exceptions."
The Privacy Angle
This is the part that surprised me most. When I posted about BatchSet on LinkedIn, the response was not "this is fast." It was "this is private."
A lot of people process client work, product photos, or internal documents they do not want on a cloud server. "We delete after an hour" is not enough for them. "Never uploaded" is.
The browser-side approach means:
- Your images never touch a server
- There is no network upload time
- There is no queue
- There is no "trust us" privacy policy
The trade-off is that your device does the work. On an old laptop with 4GB RAM, a 200-image batch will struggle. On a modern machine, it flies.
Performance: Browser vs Server
I ran a rough benchmark converting 100 product images (average 2.4MB JPG) to WebP at 80% quality, on my own connection and machine. Your numbers will differ, especially on upload-heavy paths:
| Approach | Time | Upload? | Where it runs |
|---|---|---|---|
| Browser, 8 cores | 48s | No | Local |
| Browser, 4 cores | 1m 35s | No | Local |
| Typical server-based converter (free tier) | 4-7m | Yes | Server |
The browser wins on wall-clock time mostly because there is no upload/download round-trip. The actual encoding is roughly comparable; skipping the network is what saves minutes.
The Code
Here is a minimal version of the core worker that handles conversion:
// image-worker.js
self.onmessage = async (e) => {
const { file, format, quality, maxWidth } = e.data;
try {
// Decode
const bitmap = await createImageBitmap(file);
// Calculate new dimensions
let { width, height } = bitmap;
if (maxWidth && width > maxWidth) {
height = Math.round(height * (maxWidth / width));
width = maxWidth;
}
// Draw and encode
const canvas = new OffscreenCanvas(width, height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0, width, height);
const mime = format === 'webp' ? 'image/webp' : 'image/jpeg';
const blob = await canvas.convertToBlob({ type: mime, quality });
// Send back
self.postMessage({ blob, name: file.name });
} catch (err) {
self.postMessage({ error: err.message });
}
};
The main thread just coordinates: it distributes files to workers, collects results, and streams them into JSZip.
What I Learned
1. RAM is the real bottleneck. CPU is rarely the issue. Memory management and incremental ZIP writing matter more than parallelization.
2. Progress bars are non-negotiable. When processing 200 images, users need to see movement. A frozen UI feels broken even when the worker is busy.
3. Fallbacks matter. Safari handles OffscreenCanvas differently from Chrome. Firefox has different memory limits. Test on all three.
4. Honest limitations build trust. I originally hid the fact that large batches could crash the tab. Now the UI warns you: "Very large batches are limited by your device's RAM." Users appreciate that more than a silent crash.
Try It
The core image converter is free and requires no account:
batchset.com/tools/bulk-image-converter
Drop a folder of images, pick WebP or JPG, set your quality, and download the ZIP. Everything happens in your browser.
The other tools (barcodes, QR codes, URL shortening, social resizing, AI background removal) are built around the same principle: do as much as possible locally, and only hit the server when the browser genuinely cannot.
Questions?
Happy to answer anything about the browser-side pipeline, memory management with large batches, or where this approach falls apart. Drop a comment below.
Built with Next.js 14, Web Workers, and too much coffee.
Top comments (0)