DEV Community

Ali Baba
Ali Baba

Posted on

How I Built a Client-Side HEIC to JPG Converter Using WebAssembly (No Server Required)

If you've ever tried to open a photo taken on an iPhone on a
Windows PC, you've probably run into the HEIC format — Apple's
default image format since iOS 11. It's efficient, but almost
nothing outside the Apple ecosystem can open it natively.

Most online converters solve this by uploading your file to a
server, converting it there, and sending it back. That works,
but it means your photos — sometimes personal ones — are
leaving your device.

I wanted to build something different: a converter that runs
entirely in the browser, with zero uploads. Here's how I
did it with WebAssembly.

Why WebAssembly?

The HEIC format is complex. Decoding it requires a C library
called libheif, which isn't natively available in JavaScript.
But thanks to WebAssembly, we can compile C/C++ code and run
it directly in the browser at near-native speed.

The library I used: libheif.js — a WebAssembly port of
libheif that runs entirely client-side.

The Basic Flow

// 1. User drops a .heic file
// 2. Read it as ArrayBuffer
// 3. Pass to libheif.js for decoding
// 4. Get raw pixel data back
// 5. Draw on Canvas
// 6. Export as JPG via canvas.toBlob()

async function convertHEICtoJPG(file) {
  const buffer = await file.arrayBuffer();
  const uint8Array = new Uint8Array(buffer);

  // Initialize libheif decoder
  const decoder = new libheif.HeifDecoder();
  const data = decoder.decode(uint8Array);

  // Get first image
  const image = data[0];
  const width = image.get_width();
  const height = image.get_height();

  // Draw to canvas
  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');

  await new Promise((resolve, reject) => {
    image.display(
      { data: ctx.createImageData(width, height), 
        width, height },
      (displayData) => {
        if (!displayData) return reject('Failed');
        ctx.putImageData(displayData, 0, 0);
        resolve();
      }
    );
  });

  // Export as JPG blob
  return new Promise(resolve => {
    canvas.toBlob(resolve, 'image/jpeg', 0.92);
  });
}
Enter fullscreen mode Exit fullscreen mode

The Privacy Advantage

Since everything runs in the browser:

  • ✅ No file is ever uploaded to a server
  • ✅ Works offline after first load
  • ✅ No file size limits imposed by server costs
  • ✅ Conversion speed depends on the user's device, not yours

This matters more than people think. HEIC files are often
personal photos. Users shouldn't have to trust a random server
with them.

Handling Multiple Files

Batch conversion is straightforward — just iterate and
trigger downloads:

async function convertBatch(files) {
  for (const file of files) {
    const blob = await convertHEICtoJPG(file);
    const url = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = url;
    a.download = file.name.replace(/\.heic$/i, '.jpg');
    a.click();

    URL.revokeObjectURL(url);
  }
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

WebAssembly is fast, but HEIC decoding is still CPU-intensive.
A few things I learned:

  • Web Workers: Move the decoding off the main thread to avoid freezing the UI
  • Progress feedback: For large files or batches, show progress — users will wait if they know something is happening
  • Memory management: Call image.free() after each conversion to avoid memory leaks with large batches

The Result

The converter is now live at
AwesomeToolkit.com/tools/heic-to-jpg
as part of a larger free toolkit I'm building.

It handles single files, batch conversion, and outputs either
JPG or PNG — all without a single byte leaving your browser.

What's Next

I'm applying the same approach to other conversions that can
run client-side:

  • WebP ↔ JPG/PNG (using Canvas API directly)
  • PNG to ICO/favicon (multi-resolution, no server needed)
  • Image compression (using browser-native APIs)

For heavier operations like video compression or PDF
processing, I'm using FFmpeg.wasm — same concept, different
library. I'll write about that next.


If you're building something similar or have questions about
the libheif.js implementation, drop a comment — happy to help.

Top comments (0)