Every iPhone since iOS 11 saves photos as HEIC by default. Send one to a Windows laptop, an upload form or an older CMS and it often fails. The usual fix is an online converter that uploads your photo to a server.
You don't need the server. A browser can decode HEIC itself, and where it can't, a WebAssembly build of libheif can. Here's how that pipeline works, including a bug in libheif-js that cost me an afternoon.
Step 1: check the bytes, not the file name
People rename files. A HEIC saved as .jpg exists, and so does a JPEG saved as .heic. So the first job is to read the file header.
HEIF files are ISO-BMFF containers. Bytes 4 to 8 spell ftyp, and bytes 8 to 12 are the "major brand":
const HEIC_BRANDS = ["heic", "heix", "hevc", "hevx", "heim", "heis", "hevm", "hevs", "mif1", "msf1"]
function ascii(b, start, end) {
let s = ""
for (let i = start; i < end && i < b.length; i++) s += String.fromCharCode(b[i])
return s
}
async function isHeic(file) {
const b = new Uint8Array(await file.slice(0, 12).arrayBuffer())
if (b.length < 12 || ascii(b, 4, 8) !== "ftyp") return false
return HEIC_BRANDS.includes(ascii(b, 8, 12).toLowerCase())
}
mif1 and msf1 matter. Some phones and editors write those generic brands instead of heic.
Step 2: let the browser try first
Safari 17 and later can decode HEIC natively. If createImageBitmap(file) works, you're done, and you never download any WASM:
async function tryNative(file) {
try {
return await createImageBitmap(file, { imageOrientation: "from-image" })
} catch {
return null // Chrome, Firefox and Edge on most systems end up here
}
}
Step 3: fall back to libheif, loaded only when needed
For everyone else, libheif compiled to WebAssembly does the decoding. It's about 520 KB gzipped, so import it lazily, the first time a HEIC file actually shows up:
let libPromise = null
function loadLibheif() {
libPromise ??= import("libheif-js/libheif-wasm/libheif-bundle.mjs")
.then(async (m) => {
const lib = m.default()
if (lib.ready) await lib.ready
return lib
})
.catch((err) => {
libPromise = null // one failed chunk load must not break every later photo
throw err
})
return libPromise
}
Resetting libPromise in the catch matters. Without it, one flaky network moment poisons every photo after it.
Step 4: pick the primary image, not the first one
A HEIC file can hold several images: the photo, a thumbnail, a depth map. You want the one the container marks as primary. libheif-js has a method for exactly that on each image:
const primary = images.find((im) => im.is_primary()) // don't do this
In libheif-js 1.19.8, HeifImage.prototype.is_primary() calls a global function that doesn't exist, so it throws a ReferenceError. My tests used synthetic files with a single image, so they never reached that line. The first real phone photo did.
The fix is to call the underlying embind export yourself and treat any failure as "not primary":
function isPrimary(lib, im) {
try {
return im.handle !== undefined && !!lib.heif_image_handle_is_primary_image(im.handle)
} catch {
return false
}
}
const primary = images.find((im) => isPrimary(lib, im)) ?? images[0]
Lesson: test with real files from real phones. Synthetic files only exercise the code paths you already thought about.
Step 5: refuse huge photos before you allocate
Decoding gives you raw RGBA, four bytes per pixel. A 48 MP photo is about 190 MB before you've drawn anything. I refuse anything over 64 MP before decoding (a 64 MP RGBA buffer is about 256 MB), and free every image handle and the decoder in a finally block, including on failure.
Orientation is the easy part. libheif applies the container's rotation and mirror boxes (irot, imir), so the pixels come out upright. When you write the JPG, set EXIF Orientation to 1, or viewers will rotate the photo a second time.
Step 6: one worker per photo, and fewer on phones
Decoding is CPU-heavy, so it runs in Web Workers. The tempting move is one worker per core. On a phone that crashes the tab, because memory runs out before the CPU does. So the pool size depends on the device:
function pickConcurrency({ cores, deviceMemory, mobile }) {
const c = cores && cores > 0 ? cores : 4
// A 48 MP decode peaks near 1 GB, and Android's low-memory killer
// takes the whole tab, not just the worker.
if (mobile || (deviceMemory !== undefined && deviceMemory <= 4)) return 1
return Math.max(1, Math.min(4, c))
}
Read the inputs from navigator.hardwareConcurrency, navigator.deviceMemory and navigator.userAgentData?.mobile. Mobile or 4 GB of RAM or less means one photo at a time. It's slower, but the tab survives a batch of 50.
Step 7: keep the useful metadata, drop the GPS
Drawing to a canvas strips all metadata. That's good for privacy, but people do want their capture date in the converted JPG. So after encoding I write a small EXIF block back: date taken, camera and lens. GPS goes in only if the user asks for it.
The result
A HEIC converter built this way never sends the photo anywhere. In the network log there's no image upload at all: just the page, the WASM chunk the first time, and analytics pings. If you'd rather not build it, this is the pipeline behind the free HEIC to JPG converter on BatchSet (there's a HEIC to PNG version for lossless output).
The short version:
- Sniff
ftypbrands, don't trust extensions. - Try native decode first, load WASM lazily.
- Don't call
is_primary()in libheif-js 1.19.8. - Size the worker pool by memory, not cores.
This article was drafted with an AI assistant from the code of a production tool. The snippets are taken from that code.
Top comments (0)