DEV Community

Nina Hart
Nina Hart

Posted on

How a HEIC file becomes a JPG inside your browser

Someone sends you a photo from an iPhone. You drop it into a web form. The form says "unsupported file type" and that's the end of the conversation.

The file is a .heic. It is a perfectly normal photo. Your browser just can't read it.

This post is about why, and about what actually happens when you fix it client-side.

What HEIC actually is

HEIC is not an image format in the way JPEG is. It's a container — HEIF — holding an image encoded with HEVC (H.265), the same codec used for 4K video.

Apple switched the iPhone camera default to it in 2017 with iOS 11. The reasons were boring and good: half the file size of JPEG at the same visual quality, plus support for depth maps, HDR, and image sequences in the same container.

The problem is the second half of that sentence. HEVC is patent-encumbered. Shipping a decoder means paying for a licence, so most browser engines simply don't. Safari decodes HEIC because it sits on top of the operating system's own codecs. Chrome, Firefox, and Edge on Windows generally will not, and <img src="photo.heic"> just fails.

So the file is fine. The browser is the one that's missing a part.

Three ways out

There are basically three options when you control the site:

  1. Ask the user to convert it themselves. Bad. They have a photo, not a workflow.
  2. Upload it and convert on a server. Works, but now someone's unreleased product photo is sitting on your disk, and you're paying for CPU to decode video frames.
  3. Ship a decoder to the browser and do it locally. Costs bandwidth, saves everything else.

Option 3 is the interesting one, and it's more practical than it sounds.

Shipping the decoder, but only when it's needed

The decoder is a compiled build of libheif wrapped in JavaScript. A minified build lands around 1.3 MB — bigger than your average JS bundle, smaller than a single photo from a modern phone.

The obvious mistake is putting that in your main bundle, where every visitor pays for it whether they have a HEIC or not. Instead, load it on demand, after you've confirmed the file really is HEIC.

That means sniffing the file itself rather than trusting the extension or the MIME type. Both lie: a .heic can be a renamed JPEG, and browsers report image/heic inconsistently.

A HEIF file starts with a box header, and the brand is ASCII at a fixed offset:

async function looksLikeHeic(file) {
  const buf = new Uint8Array(await file.slice(0, 32).arrayBuffer());
  const ascii = (from, to) =>
    String.fromCharCode(...buf.slice(from, to));

  // ISO base media file: bytes 4..8 are 'ftyp'
  if (ascii(4, 8) !== 'ftyp') return false;

  // Brand lives at 8..12; compatible brands follow.
  const brand = ascii(8, 12);
  const heifBrands = ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'];
  return heifBrands.includes(brand) ||
         heifBrands.some(b => ascii(16, 32).includes(b));
}
Enter fullscreen mode Exit fullscreen mode

Thirty-two bytes, no dependency, and you can decide whether to fetch 1.3 MB before you fetch it.

let decoderPromise = null;

function loadDecoder() {
  // The build is a single file that attaches a global.
  decoderPromise ??= new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = '/vendor/heic2any.min.js';
    s.onload = () => resolve(window.heic2any);
    s.onerror = reject;
    document.head.appendChild(s);
  });
  return decoderPromise;
}
Enter fullscreen mode Exit fullscreen mode

Cache the promise, not the result — two photos dropped at once should share one download, not start two.

Decoding, and the part people get wrong

The actual conversion is a one-liner:

const heic2any = await loadDecoder();

const jpgBlob = await heic2any({
  blob: file,
  toType: 'image/jpeg',
  quality: 0.92,
});
Enter fullscreen mode Exit fullscreen mode

What matters is what's around it.

Do one file at a time. Decoding is CPU-bound and the decoded frame is uncompressed — a 12-megapixel photo is roughly 48 MB in memory as RGBA. Decode ten in parallel and you'll meet the tab's memory limit on a laptop that was fine a second ago.

Keep the quality high. 0.92 is a good default: it stays visually clean at 100% zoom and still produces something a marketplace form will accept. Going lower to save bytes is better handled by resizing, which I'll come back to.

Respect orientation. Phone photos carry an EXIF orientation flag. If you decode and re-encode without honouring it, a portrait photo comes out sideways. Modern paths handle this if you ask them to — for canvas work, createImageBitmap(blob, { imageOrientation: 'from-image' }) does the right thing.

Let Safari skip the decoder. Safari can already open a HEIC. If the browser can, createImageBitmap() on the original file succeeds and you never download anything. Try the native path first; fall back to the JS decoder only when it throws.

async function toJpeg(file) {
  // Native path: Safari, and any browser that gained HEIC support.
  try {
    const bmp = await createImageBitmap(file, { imageOrientation: 'from-image' });
    const canvas = new OffscreenCanvas(bmp.width, bmp.height);
    canvas.getContext('2d').drawImage(bmp, 0, 0);
    return await canvas.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
  } catch {
    // Everything else: pay the 1.3 MB.
    const heic2any = await loadDecoder();
    return await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.92 });
  }
}
Enter fullscreen mode Exit fullscreen mode

What it actually costs

On a normal laptop, a 1440 × 960 HEIC decodes and re-encodes in a few seconds. The first photo in a session also pays the decoder download; after that it's cached and the tab even works offline.

The output is usually larger than the input, and that's expected — you're trading compression efficiency for compatibility. A 40 KB HEIC becoming a 194 KB JPG is a normal result. If size matters, resize afterwards; that's a separate lever.

Why local matters here

This is the part that made me build it.

HEIC is the iPhone default, which means the photos people convert are personal: family pictures, and for anyone selling online, product shots of things that haven't launched yet. Uploading those to a converter you found in a search result is a small decision with a long tail — you have no idea what happens to the file, how long it's kept, or who can read it.

Doing it in the tab removes the question. The file is read by FileReader, decoded by a library that's already in the page, and written back as a Blob. If you open DevTools and watch the network panel while converting, you'll see the decoder load and nothing else. The photo never moves.

That's the whole trick: the browser was missing a codec, so we shipped it one — once, lazily, and then threw it away when the tab closes.


I built this into ToolCaravan, a set of small free browser tools for product photos. No account, no upload, no watermark — because the files are the point.

Top comments (1)

Collapse
 
anthony_wft profile image
Anthony Bolduc •

Good write-up. One more thing worth deciding explicitly: metadata. Canvas-based re-encoding drops EXIF silently, which is great for privacy (no GPS in the JPG) but surprising for people who want the capture date kept in their photo library. A one-line note like "location and camera data are removed" (or an option to keep the date) avoids the confusion.

Also +1 on one-at-a-time decoding: batch HEIC on phones is exactly where tabs die.

(Disclosure: I built webfiletools.com, which has a browser-side HEIC converter.)