DEV Community

How I Added Browser-Based HEIC to JPG Conversion (No Server, No Upload)

Every iPhone since iOS 11 saves photos as HEIC by default. It's a genuinely better format - same visual quality as JPEG at roughly half the file size, thanks to being built on HEVC compression instead of the decades-old JPEG codec.

The problem is compatibility: plenty of websites, Windows apps, Android devices, and upload forms still can't open it. So "why won't this site accept my photo" is one of the most common small frustrations on the internet, and it's exactly the kind of problem a browser tool can solve without needing a server at all.

I just added a HEIC to JPG converter to FileForge Tools, my privacy-first file conversion site. Here's what building it actually involved.

Why this can't just be <img src="photo.heic">

Browsers don't natively decode HEIC. Safari can display HEIC images because Apple's OS-level codec handles it beneath WebKit, but that's not something JavaScript or the Canvas API can hook into directly - there's no ctx.drawImage() path from a raw HEIC buffer. You need an actual decoder.

That's what heic2any provides. Under the hood it's a WASM build of libheif that decodes the HEIC bitstream in-browser, hands you back a canvas-compatible image, and lets you re-encode it as JPEG (or PNG) at whatever quality you specify. The whole pipeline - decode, re-encode, download - happens in the browser's memory. Nothing is ever sent to a server.

const { default: heic2any } = await import("heic2any");
const result = await heic2any({
  blob: file,
  toType: "image/jpeg",
  quality: 0.9,
});

That's genuinely most of the API. The interesting parts were everything around it.

Batch conversion needs its own progress model

People rarely convert one HEIC photo - it's usually "I need to send my whole camera roll from this trip to someone on Windows." heic2any processes one file at a time, so batch conversion means a manual loop with per-file progress:

for (let i = 0; i < files.length; i++) {
  setProgress(`Converting ${i + 1} of ${files.length}โ€ฆ`);
  const out = await heic2any({ blob: files[i], toType: "image/jpeg", quality });
  // heic2any can return an array for multi-image HEIC containers - always handle both
  const blob = Array.isArray(out) ? out[0] : out;
  results.push({ name: files[i].name, blob });
}

That last line matters more than it looks - HEIC is technically a container format, and a single .heic file can hold multiple images (think burst shots). heic2any returns an array in that case, and if you don't check for it, you'll occasionally get [object Blob] written straight to a filename instead of an actual image.

Some HEIC files will fail, and that's fine

Not every file with a .heic extension decodes cleanly. Certain portrait-mode captures (which embed depth-map data alongside the image), some burst-photo variants, and HEIC files from non-Apple cameras use encodings the WASM decoder doesn't fully support.

Rather than let a batch conversion silently produce a corrupt file or crash the whole loop, I wrapped each conversion individually:

try {
  const out = await heic2any({ blob: file, toType: "image/jpeg", quality });
  // ...
} catch {
  setError(`"${file.name}" could not be converted - it may use a HEIC variant this browser can't decode.`);
}

One bad file in a batch of twenty shouldn't take down the other nineteen. Fail loud on the one file, keep going on the rest.

Why this specifically needed to be client-side

Photos are a different privacy category from PDFs or JSON. A camera roll can carry faces, home addresses baked into EXIF GPS data, screenshots of private conversations, IDs - genuinely sensitive stuff, uploaded to convert a file format, of all things.

Every other "HEIC to JPG" converter I checked uploads your file to a server first. For a task this mechanical, that's an unnecessary trust exercise. Running the whole thing through WASM in the browser means the conversion is provably private - you can watch your network tab, or just go offline after the page loads, and it still works. That's the actual point of doing it this way, not just an implementation detail.

Try it at fileforgetools.in/heic-to-jpg - batch convert up to 20 photos, adjustable JPG quality, nothing uploaded. Built with Next.js 14 on Vercel.

If you're building anything with heic2any and hit the multi-image-array gotcha, hopefully this saves you the debugging session it cost me.

Comments

No comments yet. Start the discussion.