Our upload component started receiving .heic files from people on iPhones, and Chrome showed an empty preview. The first proposal in review was to bundle a libheif WASM build into the component. My question was simple: what if the user's browser can already read the file? Then every byte of that decoder is wasted. So before deciding when to load a decoder, I measured which engines decode HEIC natively.
Which engines decode HEIC natively
The samples are HEIC files I generated with macOS sips from synthetic images: 3MP, 12MP (including two portrait shots) and 48MP. They are not photos straight off an iPhone. The engines are the Chromium 149, WebKit 26.5 and Firefox 151 builds bundled with Playwright 1.61.1, headless, on an Apple M4 with 16 GB. As a reference I used the HEIC reader in ImgIng on the same samples and machine, with network logging on to see when it fetches a decoder.
Read the chart column by column. The Chromium 149 and Firefox 151 builds fail on all four paths. <img> fires error, img.decode() rejects with EncodingError, and createImageBitmap rejects with InvalidStateError. ImageDecoder.isTypeSupported('image/heic') is false in both, while the same call returns true for image/avif. Setting the Blob type to image/heic changes nothing. The WebKit 26.5 build succeeds everywhere, because it calls the macOS system decoder, and portrait samples come out upright at 3024×4032. That result covers the Playwright WebKit build on macOS only; I did not test Safari releases or iOS.
The probe I use
async function decodeNatively(file) {
try {
return await createImageBitmap(file);
} catch {
return null;
}
}
If this returns a bitmap, that is the decoded frame and it goes straight to the canvas. If it returns null, I lazy-load the WASM decoder. I ran it in all three builds: it fails in 0–1 ms on Chromium and Firefox, and on WebKit it decodes the 12MP portrait sample in 71 ms. The probe is the real decode, so nothing gets decoded twice.
I avoided ImageDecoder.isTypeSupported on purpose. The WebKit build has no ImageDecoder at all. Treating "API missing" as "format unsupported" would send the one engine that can read HEIC off to download a decoder. User-agent sniffing has the same flaw, since what matters is whether a system decoder sits behind the engine.
ImgIng follows the same order but probes with <img> on a blob: URL. On Chromium and Firefox the probe fails and a "load decoder" button appears. On the WebKit build the file is marked readable and the network log shows no decoder request at all.
What the fallback costs
Look at the green bar. Loading the decoder transfers 522,557 B gzipped across two files; the bundle is 1,461,926 B unpacked, with 1,034,305 B of WASM inlined as base64 (open-source libheif 1.19.x plus libde265). Decoding a 12MP sample then takes 377 ms on Chromium 149, synchronously on the main thread. Those timings hold only for this M4, headless, and these synthetic files. Next to that, a failed probe costing under a millisecond is nothing.
We also had a compliance reviewer asking whether originals ever leave the browser. Across ImgIng's 120 test runs, reading and converting to JPG / PNG / WebP produced zero non-GET requests, which is the bar our fallback has to meet as well.
One thing I could not check: iOS may hand the page a JPEG instead of the HEIC when a photo is picked for upload. I had no device setup for that, so I don't know how many HEIC files real iPhone users will actually send. Before choosing a decoder strategy, run createImageBitmap on a .heic file in the consoles of your target browsers and see whether it resolves.

Top comments (1)
Love the probe-first approach. Shipping 500KB+ of libheif to every Chrome user who will never decode HEIC is a tax nobody notices until Lighthouse does.
One extra wrinkle: createImageBitmap(file) on a multi-image HEIC (Live Photo / burst container) can succeed on WebKit but hand you a secondary/preview frame depending on the brand list. If you go the WASM fallback path, explicitly pick the primary pitm / largest decoded frame before drawing to canvas — otherwise previews look soft and "wrong size" bugs show up only on real iPhone exports, not sips synthetics.
Also call bitmap.close() after the probe so you don't keep a 48MP decode alive while the lazy WASM fetch runs.