Disclosure: I work on QuickImgTools, which makes the HEIC to JPG converter that uses the code described below. An AI agent working for QuickImgTools drafted this article, and every claim was checked against the shipped source and the public docs linked at the end. It is marked AI-generated per DEV's guidelines. Nothing here is paid or affiliate.

If your site has an <input type="file">, iPhone users are uploading HEIC to it. Since iOS 11, iPhone 7 and newer capture photos as HEIF by default (Apple calls the setting "High Efficiency"). Outside Safari, browsers mostly can't display those files. caniuse lists HEIF as supported in Safari 17+ and not in Chrome, Edge or Firefox. HEIC is built on HEVC, which is complex and expensive to license.

You can convert on a server. We wanted the photo to stay on the user's device, so the decode runs in the tab. This post covers the chain we ended up with, the traps we hit, and what each fallback costs in bytes.

0. Detect HEIC without trusting file.type

File.type comes from the file extension and the OS, not from the bytes. MDN says plainly that uncommon types can come back as an empty string, so check the name as well:

function isHeicLike(file) { const t = (file.type || '').toLowerCase(); if (/^image\/(heic|heif)(-sequence)?$/.test(t)) return true; return /\.(heic|heif)$/i.test(file.name || ''); } 

The accept attribute needs the same belt and braces: .heic,.heif,image/heic,image/heif alongside image/*.

1. Try the browser first

If the browser can decode HEIC natively (Safari), use it: no extra download, no WASM. We try three native routes, in this order:

async function decodeNative(file) { if (typeof ImageDecoder !== 'undefined') { const buf = await file.arrayBuffer(); for (const type of ['image/heic', 'image/heif', file.type].filter(Boolean)) { try { const dec = new ImageDecoder({ data: buf.slice(0), type }); const { image } = await dec.decode({ frameIndex: 0 }); // a VideoFrame dec.close(); return image; } catch { /* try next hint */ } } } try { return await createImageBitmap(file); } catch { /* fall through */ } return loadViaImgElement(file); // <img src=objectURL>, rejects on error } 

One improvement I'd make: ImageDecoder.isTypeSupported('image/heic') is a static, promise-based check (see MDN). Calling it first lets you skip the arrayBuffer() copy entirely when the answer is no. In headless Chrome 156 on Linux it returns false, and all three native routes fail, which is expected.

2. Fall back through three WebAssembly builds of libheif

When native decode fails, we try three libraries in order. All of them wrap libheif compiled to the web:

Order Library How it loads Size from jsDelivr (raw / brotli)
2 heic2any@0.0.4 <script> on the page 1.35 MB / 262 KB
3 heic-to@1.5.2 (libheif 1.22.2) lazy, only if 2 fails 3.0 MB / 647 KB
4 libheif-js@1.23.2 wasm bundle lazy, only if 3 fails 1.99 MB / 619 KB

(Sizes measured on 11 Nov 2026 by fetching each file from jsDelivr with and without Accept-Encoding: br.)

Why three? heic2any has the simplest API, but its latest npm release (0.0.4) dates from March 2023. heic-to exists specifically to track new libheif releases, and our code comments record that it fixed many ERR_LIBHEIF failures on newer iOS photos. The raw libheif-js bundle is the last resort: you get the lowest-level API and do the pixel copy yourself.

The loop is boring on purpose. Try each decoder, keep every error, and only give up at the end:

const paths = [decodeNative, decodeHeic2any, decodeHeicTo, decodeLibheifJs]; const errors = []; for (const fn of paths) { try { const d = await fn(file); if (d && width(d) && height(d)) return d; errors.push(new Error(fn.name + ' returned empty')); } catch (e) { errors.push(e); } } throw new Error(friendlyFailure(errors)); 

The lazy loaders are a <script> injection wrapped in a promise. That promise is cached, so a 20-file batch never fetches a library twice:

let heicToReady; function ensureHeicTo() { heicToReady ??= loadScriptOnce(HEIC_TO_CDN, () => typeof HeicTo === 'function'); return heicToReady; } 

For the last resort, libheif-js hands you an image object, not a blob. You allocate the ImageData yourself and let libheif fill it:

const decoder = new libheif.HeifDecoder(); const [image] = decoder.decode(new Uint8Array(await file.arrayBuffer())); const w = image.get_width(), h = image.get_height(); const canvas = Object.assign(document.createElement('canvas'), { width: w, height: h }); const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(w, h); await new Promise((res, rej) => image.display(imageData, out => (out ? res() : rej(new Error('HEIF processing error'))))); ctx.putImageData(imageData, 0, 0); 

Note that decoder.decode() returns an array, so a multi-image HEIC needs a decision about which image to take. We take the first.

3. Encode, and don't trust toBlob either

Once you have something drawable, it's ordinary canvas work: draw, then canvas.toBlob(cb, type, quality). Three things bit us here.

Silent PNG fallback. MDN: if the requested type isn't supported, toBlob exports image/png instead. It doesn't throw. Ask for AVIF in a browser that can't encode it and you get a PNG with a .avif name. So probe once with a 2×2 canvas, cache the answer, and assert on every real encode:

canvas.toBlob(blob => { if (!blob || blob.size === 0) return reject(new Error('Canvas export returned empty')); if (blob.type && blob.type !== mime) return reject(new Error(mime + ' encoding not supported')); resolve(blob); }, mime, quality); 

Our headless Chrome test runs report exactly this: asked for AVIF, toBlob handed back image/png. The UI tells the user to pick WebP or JPG instead of shipping a mislabeled file.

Alpha into JPEG. JPEG has no alpha channel. Fill the canvas white before drawImage when the output is JPEG, or transparent regions typically come out black.

Expect the JPEG to be bigger than the HEIC. HEIC exists because it compresses better than JPEG. On one sample photo (2448×3264, 1.56 MB HEIC) our converter produced a 1.98 MB JPEG at quality 0.92 in headless Chrome 156. That's one file, not a benchmark, but don't promise users that converting will save space. If size matters, offer WebP or a max-edge resize next to the format picker.

4. Clean up, or mobile Safari will do it for you

A batch of full-resolution phone photos is a lot of decoded pixels. What helps:

  • Call close() on every ImageBitmap and VideoFrame as soon as it's drawn. MDN: ImageBitmap.close() "disposes of all graphical resources".
  • URL.revokeObjectURL() every preview and download URL when the user clears the list.
  • Process files one at a time, await in a plain for loop. We cap a batch at 20 files.

5. Tell users what they lost

A canvas re-encode writes fresh pixels. The EXIF block from the original container, GPS included, doesn't come along. For a privacy-minded converter that's arguably a feature, but say it on the page so nobody is surprised when "date taken" disappears.

When every decoder fails, say something useful. We look for codec-type errors (ERR_LIBHEIF, "unsupported", "HEIF processing error") and suggest Safari, or exporting a JPG from Photos, instead of dumping a stack trace.

What I'd change next

  • heic2any is loaded eagerly on the converter page today. That's about 262 KB compressed on every visit, including Safari visits where the native path should succeed first. It should be lazy like the other two.
  • Probe with ImageDecoder.isTypeSupported() before copying the file into an ArrayBuffer.
  • Move the WASM decode into a Worker with OffscreenCanvas, so a 20-file batch doesn't jank the main thread. We don't do this yet.

Try it / read the source

In our test run, the converter's network log shows only GET requests: the page, its scripts, the HEIC library from jsDelivr, and Google Analytics, which loads site-wide. Nothing carrying the photo goes out. If you want to see the chain run, open DevTools on the HEIC to JPG converter (disclosure above: I work on QuickImgTools) and watch the [heic] console lines. In our headless Chrome run, the native path failed and heic2any took over.

If you've found a HEIC that beats all four decoders, I'd like to see the error message in the comments.

Sources: Apple Support, "Using HEIF or HEVC media on Apple devices"; caniuse "HEIF/HEIC image format"; MDN pages for Blob.type, ImageDecoder, ImageDecoder.isTypeSupported(), HTMLCanvasElement.toBlob() and ImageBitmap.close(); the heic2any, heic-to, libheif-js and libheif repositories.