DEV Community

Muhaymin Bin Mehmood
Muhaymin Bin Mehmood

Posted on

How I made bulk image conversion run 100% in the browser (no server, no upload)

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 })
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode
// 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)
  })
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Batch state updates — collect finished blobs and flush to state every ~150ms instead of per-file.
  2. React.memo the cards so a finished file doesn't re-render the other 199.
const FileCard = React.memo(function FileCard({ file }) {
  // ...
})
Enter fullscreen mode Exit fullscreen mode

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)