Dev.to WebDev 🛠 Dev 👁 0 📖 4 min read

Convert images in the browser without uploading them (Canvas, createImageBitmap and WebAssembly)

Most "free online converters" work the same way: you upload a file, a server converts it, you download the result. For a holiday photo that's fine. For a scanned passport, a contract or a client's design file, it's an un

Most "free online converters" work the same way: you upload a file, a server converts it, you download the result. For a holiday photo that's fine. For a scanned passport, a contract or a client's design file, it's an unnecessary risk.

While building FormatMath I wanted the opposite: the file never leaves the device. Modern browsers can do most of this work on their own. Here is how, plus the gotchas I hit along the way.

The basic pipeline: decode → draw → encode

For formats the browser already understands (JPEG, PNG, WebP, GIF, BMP, often AVIF), conversion is three steps:

  1. Decode the file into a bitmap with createImageBitmap().
  2. Draw it onto a canvas.
  3. Encode the canvas to the target format.
async function convertImage(file, type = 'image/jpeg', quality = 0.9) {
  const bitmap = await createImageBitmap(file);
  const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
  const ctx = canvas.getContext('2d');

  // JPEG has no transparency: paint a white background first,
  // otherwise transparent pixels turn black.
  if (type === 'image/jpeg') {
    ctx.fillStyle = '#fff';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
  }

  ctx.drawImage(bitmap, 0, 0);
  bitmap.close(); // free memory early

  return canvas.convertToBlob({ type, quality });
}

Wiring it to a file input and a download link:

input.addEventListener('change', async () => {
  const file = input.files[0];
  const blob = await convertImage(file, 'image/webp', 0.85);

  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = file.name.replace(/\.[^.]+$/, '') + '.webp';
  a.click();
  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
});

No fetch, no FormData, no server. Open DevTools → Network while converting and you'll see nothing go out. That's also the easiest way to verify any tool that claims "we don't upload your files".

Gotcha 1: the browser may silently ignore your format

If the browser can't encode the type you asked for, convertToBlob() / toBlob() doesn't throw. It quietly returns a PNG. Always check the result:

if (blob.type !== type) {
  console.warn(`This browser can't encode ${type}, got ${blob.type}`);
}

Encoder support varies more than decoder support, especially for WebP and AVIF output. When you need guaranteed output, ship an encoder compiled to WebAssembly (for example the codecs from the Squoosh project) instead of relying on the canvas.

Gotcha 2: HEIC needs WebAssembly

iPhone photos are HEIC by default. Safari can decode them natively, but Chrome and Firefox generally can't, so createImageBitmap() will reject. The fix is a decoder compiled to WebAssembly, typically based on libheif:

  1. Read the file as an ArrayBuffer.
  2. Decode it in WASM to raw RGBA pixels.
  3. Put the pixels into ImageData and continue with the canvas pipeline above.

The same idea works for camera RAW, TIFF, PSD and other formats the browser doesn't know: compile a decoder, get pixels, reuse the encoder.

Gotcha 3: metadata disappears (usually a good thing)

Drawing to a canvas keeps only pixels. EXIF data, including GPS location, camera model and timestamps, is dropped. For privacy that's a feature: a photo re-encoded this way no longer leaks where it was taken.

Two side effects to handle:

  • Orientation. Phones often store a rotation flag instead of rotating pixels. Current browsers apply it in createImageBitmap() by default (imageOrientation: 'from-image'), but test with a portrait phone photo anyway.
  • Colour profiles. Wide-gamut images (Display P3) can look slightly duller after conversion if you don't handle colour space. For most web use it's acceptable; for print work, it isn't.

If you want to keep metadata, you have to read it before decoding and write it back into the new file with a small EXIF library.

Gotcha 4: big images and memory

Canvas size is limited, and the limit differs by browser and device. Mobile Safari is the strictest. A 50-megapixel RAW file can fail where a 12 MP photo works fine. What helps:

  • Do the work in a Web Worker with OffscreenCanvas so the UI doesn't freeze.
  • Process files one at a time in batch mode, and call bitmap.close() / revoke object URLs as soon as you're done.
  • Offer a downscale option for very large inputs.

What about PDFs?

Same philosophy, different libraries:

  • PDF → image: render each page with pdf.js into a canvas, then encode as above.
  • Merge, split, rotate: pdf-lib manipulates the document structure directly in JavaScript, without re-rendering pages.
  • Compress: usually means re-encoding embedded images at lower quality, which brings you back to the canvas or a WASM encoder.

Is client-side always better?

Not always. Server-side conversion still wins for very large batches, exotic formats with no WASM build, or when you need identical output on every device. But for everyday tasks like HEIC → JPG, PNG → WebP or merging two PDFs, the browser is more than capable, and the privacy story becomes "we never had your file" instead of "trust us, we delete it".

If you just want the tools rather than the code, they're all on FormatMath: HEIC to JPG, PDF to JPG, Merge PDF and a set of developer tools. Everything runs locally in your browser.

Have you built something client-side that people assumed needed a server? I'd like to hear about it in the comments.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.