My thesis project has an avatar upload page. A classmate tried it with a photo from her iPhone and got a broken-image icon. No error in the console, nothing in the network tab that looked wrong. The file on the server was fine and opened in Preview on my Mac. It just had a .heic extension, and I had never really thought about what that meant.
HEIC is the format iPhone cameras save photos in by default. The picture inside is compressed with HEVC, the same codec that video people call H.265. That is great for storage, but a browser can only show the file if it ships an HEVC decoder of its own. I did not know that yet. My first guess was something much more boring.
Is it the Content-Type?
My local test server was Python's built-in http.server, and it serves .heic as application/octet-stream, which basically means "some bytes, no idea what". That looked like the obvious culprit. So I fetched the file, wrapped it in a new Blob with the type set to image/heic, and tried again. Same failure. That was the moment I stopped blaming my server and started testing the browsers themselves.
I ran the same files through the three browser engines bundled with Playwright 1.61.1: Chromium 149, a WebKit 26.5 build and a Firefox 151 build, all headless on an M4 Mac. The samples are not real iPhone photos. I generated synthetic images with a script and encoded them to HEIC with the macOS sips command, at 3MP, 12MP and 48MP. Out of curiosity I parsed one of them: the 12MP file is not one picture but 48 tiles of 512x512 stitched together. For comparison I ran the same samples through the format converter I use, ImgIng (https://imging.ai/), on the same three engines.
Four native ways to read an image, four failures
I tried every native path I could find. A plain <img src>. img.decode(), which resolves once the image is actually decoded. createImageBitmap(blob), which turns a file into a bitmap you can draw on a canvas. And ImageDecoder from WebCodecs, a newer API that lets you ask isTypeSupported('image/heic') before trying.
In Chromium and the Firefox build, <img> fires an error event, decode() rejects with EncodingError, createImageBitmap rejects with InvalidStateError, and isTypeSupported('image/heic') returns false. The part that surprised me is that the Firefox build already has ImageDecoder, and both engines answer true for image/avif. They handle new formats fine. They just don't handle this one. All five sample files behaved identically. I have read that HEVC patent licensing is the reason these engines don't ship a decoder, but that is not something I could check myself.
The WebKit build decoded everything, and a portrait sample came out as 3024x4032, upright. It does this by calling the macOS system decoder. That is a Playwright WebKit build on a Mac, not Safari, and I tested neither Safari on iOS nor WebKit on any other OS, so I am not claiming anything about them.
What I do now
I no longer guess from the user agent. I try createImageBitmap on the file first. If it works, I use the result. If it throws, I load a WASM decoder (libheif, a C library compiled to run in the browser) and decode it there. The comparison tool does the same thing: on the Chromium and Firefox builds it shows a "load decoder" button after the upload, and the first download is about 510 KB. On the WebKit build native decoding worked and it never fetched a decoder at all.
If you have a HEIC file around, open the console in Chrome and call createImageBitmap on it, then ask ImageDecoder.isTypeSupported('image/heic'). If you get InvalidStateError and false, the problem is missing support, and changing your headers won't fix it.
Top comments (0)