Converting iPhone HEIC Photos to JPG Inside the Browser
Someone sends you twenty photos from their iPhone. You copy them to a Windows laptop, and every file ends in .heic. The photo viewer asks you to install a codec, and the visa portal you need them for says “JPG or PNG only”. I built ToolOnTap’s HEIC to JPG converter for exactly this, and the interesting part was making the decoder run in the browser without slowing down the rest of the site.
Why HEIC files get stuck
iPhones save photos as HEIC by default. HEIC is the HEIF container (ISO/IEC 23008-12) with the picture compressed as HEVC, the same codec used for a lot of modern video. It gives smaller files than JPG at the same visible quality, which is why Apple chose it.
The problem is support. Windows needs an extra codec to show HEIC, older Android phones and many photo editors can’t open it, and most upload forms only list JPG and PNG. Among the browsers, only Safari decodes HEIC natively. Chrome, Edge and Firefox can’t draw a HEIC file, so an <img> tag or a canvas can’t help.
This affects the rest of the site too. If you drop a HEIC photo on the compressor or the resizer, those tools would fail halfway through decoding. So the shared file check catches HEIC first and sends you to the converter:
// Only Safari can decode HEIC natively, so send these to the converter instead of failing later
if (isHeic(file) && !accepts(accept).some(t => t === '.heic' || t.startsWith('image/hei'))) {
return {
message: `${file.name} is a HEIC photo, which most browsers can’t open directly.`,
link: { to: '/heic-to-jpg', label: 'Convert it to JPG first' },
}
}
isHeic looks at both the MIME type and the file name, in case the browser gives the file an empty or generic type.
Decoding HEIC without the browser’s help
Since the browser can’t decode the format, the decoder has to ship with the page. The converter uses heic2any, an open-source library that bundles libheif, the reference HEIF library, compiled to JavaScript. The whole conversion in my code is a few lines:
export function useHeic() {
async function heicToJpg(file: File): Promise<Blob> {
// Dynamic import: heic2any is large and needs window
const { default: heic2any } = await import('heic2any')
const out = await heic2any({ blob: file, toType: 'image/jpeg', quality: 0.9 })
return Array.isArray(out) ? out[0]! : out
}
return { heicToJpg }
}
Inside, heic2any does three things. It starts a Web Worker that holds the libheif code, so decoding doesn’t freeze the page. The worker turns the HEIC into raw pixels. Back on the main thread, the pixels are drawn onto a canvas, and the browser’s own JPG encoder saves the canvas at the quality I pass in.
An iPhone photo isn’t one picture inside the file. It’s many small HEVC tiles plus a grid that says how they fit together, which I describe in reading a HEIC file’s EXIF by hand. libheif assembles the tiles, so the converter gets the full photo back.
Safari takes the same route as every other browser here, even though it could decode HEIC by itself, so a photo converts the same way on every device.
Keeping 1.3 MB off every other page
The heic2any file in node_modules is about 1.3 MB before compression. That’s a lot of JavaScript for a page whose job is to load fast, and most visitors to most pages never pick a HEIC file.
The dynamic import('heic2any') above already keeps it out of the main bundle. The library only downloads when the first file reaches heicToJpg. The “needs window” in the comment matters too. heic2any creates its worker as soon as the module loads, and the pages are prerendered on a server where there’s no window, so a static import would break the build.
There was a second, less obvious problem. Nuxt adds prefetch hints for code that a page might need later, and the browser downloads those files in the background once the page is idle. The compressor and the resizer share the image tool component with the HEIC converter, so their pages were quietly prefetching the whole decoder before anyone chose a file. A hook in nuxt.config.ts turns that off for the heavy libraries:
'build:manifest'(manifest) {
for (const [id, chunk] of Object.entries(manifest)) {
// By module path, or by chunk name for libraries bundled as shared chunks (_hash.js)
if (/heic2any|pdfjs-dist|pdf-lib|jszip|cropperjs|browser-image-compression/.test(`${id} ${chunk.name ?? ''}`)) chunk.prefetch = false
}
},
The check uses both the module path and the chunk name, because Vite sometimes bundles a library into a shared chunk with a hashed file name, and then the path alone doesn’t mention it.
Quality, size and what’s left behind
The JPG is saved at 90% quality. At that setting the difference from the HEIC is hard to see without zooming in.
Expect the JPG to be bigger than the HEIC, often noticeably. That surprises people who think of conversion as compression. HEVC is simply a more efficient codec than JPG, so the same picture needs more bytes as a JPG. If the form also has a size limit, compress the JPG afterwards.
The JPG is made from the decoded pixels alone, so the photo’s metadata isn’t copied across. The GPS location, the camera model and the date taken are gone from the JPG. For most uploads that’s a bonus. If you need the date, keep the original HEIC.
Limits I chose to keep
- One photo at a time. The file queue processes files one after another. A decoded 12-megapixel photo takes about 48 MB of memory as raw pixels (4 bytes per pixel), and decoding several at once is how phone tabs crash. A big batch takes longer, but it finishes.
- 50 MB per file, the same limit as the other image tools. Ordinary iPhone photos are far below it.
- The first image only. A HEIC file can hold more than one image, such as a burst. The converter keeps the first one, which is the photo itself for normal camera shots.
- Speed on older phones. Decoding HEVC in JavaScript is heavy work, so an older phone can take a few seconds per photo. A laptop is much quicker.
If you’d rather avoid HEIC altogether, the iPhone can save JPGs from now on: Settings › Camera › Formats › Most Compatible. Photos you’ve already taken stay HEIC, which is where the converter helps.
Try it
The HEIC to JPG converter takes one photo or a whole folder and gives you back JPGs, one by one or as a ZIP. The photos are decoded on your device and never uploaded. For more image jobs, see the image tools.