I got tired of online image converters that upload my files to a server just to change a PNG to a JPG. Every single one of them sends your images to some remote machine, processes them there, and sends them back. That means your files sit on someone else's server, the conversion depends on your upload speed, and batch converting 50 images takes forever.
So I built Pixformat, a free image converter that runs entirely in the browser. No uploads, no servers, no waiting. Your files never leave your device.
But making it fast was the real challenge. Converting a single image on the main thread is straightforward. Converting 50 images without freezing the UI required a completely different architecture. Here is how I solved it.
The Problem with Main Thread Conversion
The basic approach to client side image conversion in JavaScript looks like this:
function convertImage(file, format) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
canvas.toBlob(blob => resolve(blob), `image/${format}`, 0.82);
};
img.src = URL.createObjectURL(file);
});
}
This works fine for one image. But canvas.toBlob is synchronous and CPU intensive. When you try to convert 30 images in a loop, the main thread locks up. The UI freezes. Progress bars stop moving. The browser might even show a "page unresponsive" warning.
The solution: move the heavy work off the main thread entirely.
Web Workers and OffscreenCanvas
Web Workers run JavaScript in a background thread. They cannot access the DOM, but they can do computation without blocking the UI. The key insight is that modern browsers support OffscreenCanvas, which lets you use the Canvas API inside a Worker.
The architecture looks like this: the main thread handles file input and UI updates. A pool of Web Workers handles the actual pixel processing. Each Worker receives an ImageBitmap, draws it onto an OffscreenCanvas, and calls convertToBlob to produce the output file.
Here is the Worker code, which is surprisingly compact:
self.onmessage = function(e) {
const { bitmap, mime, quality, fillWhite } = e.data;
try {
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext("2d");
if (fillWhite) {
ctx.fillStyle = "#FFFFFF";
ctx.fillRect(0, 0, bitmap.width, bitmap.height);
}
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
canvas.convertToBlob({ type: mime, quality })
.then(blob => self.postMessage({ ok: true, blob }))
.catch(err => self.postMessage({ ok: false, error: err.message }));
} catch (err) {
self.postMessage({ ok: false, error: err.message });
}
};
A few things worth noting. The fillWhite flag is for JPEG conversion, since JPEG does not support transparency and you need to fill the background before drawing. The bitmap.close() call releases the ImageBitmap memory immediately after drawing, which matters when you are processing dozens of large images. And the entire Worker is created from a Blob URL, so there is no separate file to host.
Building the Worker Pool
Instead of creating a new Worker for every image, I pre allocate a pool of Workers equal to navigator.hardwareConcurrency, which gives you the number of logical CPU cores. On most machines this is 4 to 16 Workers.
const MAX_CONCURRENCY = navigator.hardwareConcurrency || 4;
const workerPool = [];
function ensureWorkerPool() {
// Feature detection first
if (typeof OffscreenCanvas === "undefined") return;
if (typeof createImageBitmap !== "function") return;
const testCanvas = new OffscreenCanvas(1, 1);
if (!testCanvas.getContext("2d")) return;
if (typeof testCanvas.convertToBlob !== "function") return;
const code = `/* Worker code from above */`;
const blobUrl = URL.createObjectURL(
new Blob([code], { type: "application/javascript" })
);
for (let i = 0; i < MAX_CONCURRENCY; i++) {
workerPool.push({ worker: new Worker(blobUrl), busy: false });
}
}
The feature detection is critical. Not every browser supports OffscreenCanvas with a 2D context. Safari added support relatively recently. If the feature check fails, the converter falls back gracefully to main thread conversion, one image at a time. The user still gets their converted files, just slower.
Semaphore Based Concurrency
With the Worker pool ready, the conversion engine uses a simple semaphore pattern. A cursor tracks which image to process next. Each Worker picks up the next pending image when it finishes its current one.
let cursor = 0;
function processNext() {
if (cursor >= pending.length) return Promise.resolve();
const idx = cursor++;
const item = pending[idx];
return convertFile(item.file, format)
.then(result => {
if (result.ok) {
item.status = "ok";
item.blob = result.blob;
} else {
item.status = "err";
}
updateTileUI(item);
updateProgressBar(++done / pending.length);
return processNext();
});
}
// Launch all lanes simultaneously
const lanes = [];
for (let i = 0; i < concurrency; i++) {
lanes.push(processNext());
}
await Promise.all(lanes);
This gives you true parallel processing. On an 8 core machine, 8 images are being converted simultaneously. The progress bar updates smoothly because each Worker completion triggers a UI update on the main thread via postMessage.
AVIF Support Detection
AVIF is a next generation image format that delivers 30 to 50 percent smaller files than JPEG at comparable quality. But browser support is not universal yet, so Pixformat detects it at runtime.
function detectAvif() {
const canvas = document.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
canvas.toBlob(blob => {
if (blob && blob.type === "image/avif") {
// Add AVIF to the output format dropdown
}
}, "image/avif", 0.5);
}
The trick is simple. You ask the browser to encode a 1x1 canvas as AVIF. If the resulting Blob has the correct MIME type, AVIF is supported. If it does not, the browser silently falls back and you just do not show AVIF as an option. This detection runs during idle time using requestIdleCallback so it never affects page load performance.
Quality Calibration
Each output format has a carefully chosen quality setting:
| Format | Quality | Rationale |
|---|---|---|
| WebP | 0.82 | Perceptual transparency threshold. SSIM above 0.995 compared to maximum quality. |
| JPEG | 0.85 | Sweet spot for photographic content. SSIM above 0.98 at this level. |
| PNG | N/A | Lossless codec. Quality parameter is ignored by all browsers. |
| AVIF | 0.65 | Perceptual equivalence to WebP at 0.82 based on DSSIM measurements. |
These are not arbitrary numbers. I tested each setting against reference images using structural similarity metrics to find the point where file size drops significantly but visual quality remains indistinguishable to the human eye.
Performance Results
On a MacBook with an M series chip, converting 100 mixed format images to WebP takes about 3 seconds with the Worker pool versus 18 seconds on the main thread. That is a 6x speedup with zero UI freezing.
The architecture scales naturally. Machines with more cores process faster. Machines with fewer cores still work, just with fewer parallel lanes. And if Workers are not available at all, the fallback path handles everything correctly.
What I Learned
Feature detection matters more than you think. I initially assumed OffscreenCanvas was widely supported. It is now, but the 2D context support and convertToBlob method were added at different times in different browsers. Testing each capability individually avoids cryptic failures.
Yielding to the main thread is essential. Even with Workers handling the heavy processing, the main thread still has to manage file input, tile rendering, and progress updates. I added explicit yield points using scheduler.yield with a setTimeout fallback to keep interactions responsive during setup.
Memory management cannot be an afterthought. Each image creates an ObjectURL for the thumbnail, an ImageBitmap for the Worker, and a Blob for the output. Without explicit cleanup via URL.revokeObjectURL and bitmap.close, memory usage climbs fast when processing large batches.
Try It
You can use Pixformat at pixformat.com. It supports JPEG, PNG, WebP, GIF, BMP, and SVG as input, and converts to WebP, PNG, JPEG, or AVIF. Everything runs in your browser. No signups, no file limits, no ads.
If you are interested in the code, check out the source on GitHub. The architecture patterns described here are applicable to any CPU intensive browser task: image processing, audio encoding, data transformation, or anything where you need to keep the UI alive while doing real work in the background.
About Me
I am Eneko, a freelance web developer based in Spain. I build web applications with React, TypeScript, and Python. You can find more of my work at enekomartinez.com, check out my projects on GitHub, or connect with me on LinkedIn.
What is your experience with Web Workers and OffscreenCanvas? Have you hit any edge cases I did not cover? Let me know in the comments.
Top comments (0)