DEV Community

hao jia
hao jia

Posted on

Chrome fires onerror, WebKit shows it: feature-detect HEIC before you preview


Short version: if your upload preview goes blank when someone picks an iPhone photo, the browser most likely can't decode HEIC at all, and your component is treating a decode failure as "nothing to show". Don't sniff the user agent and don't trust the .heic extension. Ask the browser to decode the actual file, and branch on the answer.

I tested this because the bug report pattern is so common: preview empty in Chrome, fine on the developer's Mac. Before touching the component I wanted to know which engines fail, how the failure surfaces in code, and whether anything about the file itself matters. Here's what three engines did with the same file.

The sample

I don't have an iPhone original I can publish, so I took a CC0 photo from Wikimedia Commons, shot on an iPhone 6 (3264×2448 JPEG, an apron at Madrid airport), and converted it to HEIC with the sips tool that ships with macOS 26.5. One copy has EXIF orientation 6, i.e. portrait. It is not straight off a phone, so nothing below covers HDR, Live Photos or depth data.

Parsing the ISOBMFF boxes of the portrait copy shows why decoding is not a small feature. The primary item is a grid built from 35 hvc1 tiles, each a 512×512 HEVC image laid out 7 × 5. The ispe property says 3264×2448, landscape; the portrait orientation lives in an irot property of 270°. Upscaled to 4032×3024, the same photo becomes 48 tiles. To display it, an engine needs an HEVC decoder, has to stitch the grid and has to apply the rotation.

Three engines, three APIs

I used Playwright's bundled engines on macOS 26.5 and pointed all three APIs at the same portrait file, passed in as the File object you'd get from an <input type=file>. The table is the raw outcome; sizes are naturalWidth×naturalHeight or the bitmap size:

Engine <img> createImageBitmap img.decode()
Chromium 149 onerror InvalidStateError EncodingError
Firefox 151 onerror InvalidStateError EncodingError
WebKit 26.5 2448×3264 2448×3264 2448×3264

Same HEIC in an img tag: Chromium 149 and Firefox 151 fail with onerror, WebKit 26.5 on macOS shows it upright
Sample: CC0 iPhone 6 JPEG from Wikimedia Commons, converted to HEIC with macOS sips. Not a phone original.

WebKit returns 2448×3264, so it stitched the grid and honoured irot. Two caveats I'd put in any ticket. WebKit is borrowing the macOS system decoder here. And this is Playwright's WebKit build, not Safari; I haven't tested real Safari, Windows or any phone. "Works on my Mac" tells you nothing about the Chrome user who filed the bug.

One more thing I checked: I wrapped the HEIC in a File named x.bin with an empty type. All three engines gave exactly the same results. Browsers sniff the bytes, so renaming doesn't help, and checking file.type doesn't tell you whether the preview will work.

Detect by decoding

So the check is to decode the file the user actually picked, in the browser they're actually using, before you build the preview. This is all of it, and I ran it in all three engines against the HEIC and a JPEG control:

async function supportsDecode(blob) {
  try {
    const bitmap = await createImageBitmap(blob);
    bitmap.close();
    return true;
  } catch {
    return false;
  }
}
Enter fullscreen mode Exit fullscreen mode

HEIC: false in Chromium and Firefox, true in WebKit. JPEG: true everywhere, already rotated to 2448×3264 from its EXIF. I prefer this over listening to <img> errors because it rejects into a catch you control. Don't branch on e.name though; the two failing engines use different error names across APIs.

A false doesn't mean the file is broken. Bytes 4 to 12 of the sample read ftypheic, so a header check separates "HEIC this browser can't decode" from "corrupt file". Those deserve different messages.

The fallback

Once you know, the options are:

  • Decodes: preview as usual with an object URL.
  • HEIC, can't decode: replace the empty box with a sentence like "This browser can't preview HEIC. The file is still selected." That alone kills the re-select loop.
  • Want an actual preview or a JPG: load a WASM decoder on demand (libheif builds are the usual choice) and decode locally, or upload and convert on the server.

I lean local-first. I work under compliance rules where "did the original leave the user's machine" is a question I've had to answer for months, and local decoding is the one answer that's simply "no". A converter I used as a reference, imging, does exactly this: pick a HEIC and it says the browser can't decode it natively and offers a "Load decoder" button; after loading, the file reads locally, and I saw no upload request for the original. Converting the portrait sample to JPG gave 2448×3264 with EXIF orientation 1, so no double rotation. Its HEIC output is marked server-side, so reading and writing are different stories there.

If you pick the server route, test with a multi-tile, rotated file. ffmpeg 7.1 with a plain ffmpeg -i in.heic out.png wrote 512×512, the first tile; ffprobe does report the tile grid, the default command just doesn't stitch it. Pillow 11.3 without the pillow-heif plugin raised "cannot identify image file". Both can handle HEIC with the right setup; the defaults can't.

What I still can't tell you: how a real iPhone HDR photo comes out of a WASM decoder versus a server conversion. I don't have a sample, so I'm leaving that open. The converter I referenced: https://imging.ai/

Top comments (0)