Most "image converter" sites upload your files to a server, convert them, and send them back. That means upload time, server cost, and a privacy question: where did my images go?
While building BatchSet (an image toolkit for e-commerce sellers), I wanted basic conversion — JPG/PNG → WebP — to be free and instant. The only way that math works at scale is to never touch a server. Do it all in the browser.
Here's how, and the gotcha that actually made it fast.
The naive version
The modern browser already ships everything you need: createImageBitmap + OffscreenCanvas.
async function convert(file, type = "image/webp", quality = 0.8) {
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
const ctx = canvas.getContext("2d")
ctx.drawImage(bitmap, 0, 0)
return canvas.convertToBlob({ type, quality })
}
That's a full JPG→WebP conversion, client-side, zero network.
The problem: 200 images freezes the tab
E-commerce sellers don't convert one image — they convert their whole catalog. Run the loop above over 200 files and the main thread locks up. The UI freezes, the tab goes white, users bail.
The fix: move the work off the main thread with Web Workers — and run a pool of them so you actually use every CPU core.
A tiny worker pool
// worker.js
self.onmessage = async (e) => {
const { id, file, type, quality } = e.data
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
canvas.getContext("2d").drawImage(bitmap, 0, 0)
const blob = await canvas.convertToBlob({ type, quality })
self.postMessage({ id, blob })
}
// pool.js — spin up one worker per core, capped
const SIZE = Math.min(navigator.hardwareConcurrency || 4, 8)
const workers = Array.from({ length: SIZE }, () => new Worker("worker.js"))
export function runPool(files, opts) {
return new Promise((resolve) => {
const out = []
let next = 0, done = 0
const feed = (w) => {
if (next >= files.length) return
const id = next++
w.onmessage = (e) => {
out[e.data.id] = e.data.blob
if (++done === files.length) resolve(out)
else feed(w) // worker grabs the next job
}
w.postMessage({ id, file: files[id], ...opts })
}
workers.forEach(feed)
})
}
Now 200 images convert in parallel across all cores instead of choking one thread.
The gotcha that doubled my speed
My first version re-rendered a React card for every file on every progress tick. With 200 files finishing nearly at once, that's thousands of renders — the conversion was fast but the UI made it feel slow.
Two fixes:
- Batch state updates — collect finished blobs and flush to state every ~150ms instead of per-file.
-
React.memothe cards so a finished file doesn't re-render the other 199.
const FileCard = React.memo(function FileCard({ file }) {
// ...
})
That alone took a 200-image batch from "janky" to "instant."
What you get
- No upload — files never leave the device (great for privacy + zero server cost).
- Parallel — uses every core via the worker pool.
- Free to run — it's the user's CPU, not yours.
The catch: formats like HEIC/TIFF aren't natively decodable in-browser, so those still need a server (I route only those through one). But for the 90% case — JPG/PNG/WebP — the browser does it all.
If you want to see it in action, the bulk converter on BatchSet runs exactly this. Drop in a folder of product images and watch your cores light up.
What's your take — are we underusing OffscreenCanvas + Workers for this kind of work? 👇
Top comments (0)