Every image tool that runs in the browser hits the same wall eventually: someone drops in a photo straight off an iPhone and nothing happens. No error, no preview, no conversion. The file is HEIC, and the browser has no idea what to do with it.
This is worth writing up because the workarounds people reach for first are all wrong in interesting ways.
Why the browser can't help you
HEIC is a HEIF container holding HEVC-encoded image data. HEVC is patent-encumbered, and that has kept it out of browsers: Chrome, Firefox and Edge cannot decode it. Safari can, because Apple already licenses HEVC for the rest of the platform — which is exactly why this bug is invisible if you develop on a Mac and test in Safari.
So the usual pipeline just fails:
const img = new Image();
img.src = URL.createObjectURL(file);
await img.decode(); // rejects on HEIC in Chrome, works in Safari
img.decode() rejects with a decode error. canvas.drawImage() on a never-loaded image draws nothing. If you're not awaiting the decode, you get a silently blank canvas instead of an exception, which is worse.
The trap: renaming the extension
Users do this constantly, and so do developers when they're debugging in a hurry. Renaming IMG_4021.HEIC to IMG_4021.jpg changes nothing — the bytes are still HEIF, and anything that sniffs the actual content rejects the file. Reddit, most upload forms and every real decoder look at magic bytes, not at the name.
If you want to detect HEIC properly, read the header. The ftyp box sits at offset 4 and the brand tells you what you have:
async function isHeic(file) {
const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
const boxType = String.fromCharCode(...head.slice(4, 8)); // 'ftyp'
const brand = String.fromCharCode(...head.slice(8, 12)); // 'heic','heix','mif1','msf1'
return boxType === 'ftyp' && /^(heic|heix|hevc|mif1|msf1)$/.test(brand);
}
Do not rely on file.type for this, which brings us to the second trap.
file.type is often an empty string
This one cost me a long time. Files arriving from iOS — through the share sheet, some pickers, occasionally AirDrop — frequently have file.type === ''. Not image/heic, not image/jpeg. Nothing.
Which means the validation almost everyone writes first silently rejects perfectly good photos:
// rejects real images from iPhones
if (!file.type.startsWith('image/')) return reject('Not an image');
The user sees a file they know is a photo being refused as "not an image", and there is nothing in the console to explain it. The fix is to treat an empty type as unknown rather than as invalid, and fall back to the extension or the header:
const looksLikeImage =
file.type.startsWith('image/') ||
(file.type === '' && /\.(jpe?g|png|webp|gif|heic|heif)$/i.test(file.name));
What decoding actually costs
If your tool's whole premise is that files stay on the device, you can't punt HEIC to a server. So you ship a decoder to the client — usually libheif compiled to WebAssembly, via libheif-js or heic2any.
It works. It is also the single heaviest thing in the bundle: a few hundred kilobytes of WASM that the vast majority of visitors will never need, because most people aren't uploading HEIC. Loading that eagerly to serve a minority is the wrong trade.
Lazy-load it behind the detection above, so the cost only lands on the users who actually have a HEIC file:
async function toJpegBlob(file) {
if (!(await isHeic(file))) return file; // no decoder needed
const { default: heic2any } = await import('heic2any'); // fetched now, not at boot
return heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 });
}
Two things worth knowing once it runs. Decoding is CPU-bound and blocks the main thread — a 12-megapixel photo can freeze the tab for a noticeable moment, so a Web Worker is worth the trouble if you handle batches. And Live Photos are HEIC files with multiple images inside; most decoders hand you the primary one, which is usually what people expect, but not always what they wanted.
Orientation, since you're already here
HEIC photos carry EXIF orientation, and once you've decoded to a canvas that metadata is gone. Portrait photos come out sideways. If you decode to a bitmap yourself, apply the rotation before export; createImageBitmap accepts { imageOrientation: 'from-image' } for exactly this.
The shape of the problem
Every failure here is silent. A rejected decode with no catch, an empty MIME type that reads as invalid, a renamed file that looks converted, an orientation tag dropped on the floor. Nothing throws, and the user just sees a tool that doesn't work with their photos.
That's the general lesson, and it's the same one I ran into with the mobile save button: the platform boundaries — file types, downloads, share sheets, permissions — are where things fail quietly, and where checking the outcome on a real device matters more than reading the code.
For context, this came out of building imageonlinefree.com, which converts HEIC locally. The failure modes are the transferable part.
Top comments (0)