The privacy problem with "online HEIC converters", and how running libheif in the browser fixes it.
If you've ever helped a non-technical person get an iPhone photo to open on a Windows laptop, you've hit HEIC. Since iOS 11, iPhones save photos as .heic by default — roughly half the size of JPG at the same quality — and the rest of the computing world still chokes on it. Windows shows a black thumbnail, upload forms reject the extension, older apps just shrug.
The usual fix is to Google "HEIC to JPG converter", click the first result, and upload your photos to a server you know nothing about. That's the part that always bothered me. A camera roll isn't holiday snaps — it's ID scans, medical documents, kids, things people photographed because they were private. Shipping those to a stranger's box for a format conversion, then trusting an unverifiable "deleted after 1 hour" banner, is a bad trade.
So I built the converter to run entirely in the browser — no upload, no server, nothing leaves the device. Here's how it works and what tripped me up.
The core idea: decode HEIC where the file already is
HEIC is HEVC-compressed image data in a container. Browsers can't decode it natively (that's the whole problem), so you ship the decoder yourself. Under the hood that's libheif compiled to WebAssembly — I use the heic2any wrapper around it, which handles the decode-and-re-encode in one call.
The flow is entirely client-side — no fetch, no upload endpoint, no backend:
File (.heic)
→ heic2any (libheif WASM) decodes + re-encodes
→ Blob (image/jpeg or image/png)
→ URL.createObjectURL → download
You can prove it: open DevTools → Network tab, drop a file, and watch nothing happen. The page loads, then silence.
The whole conversion is a few lines. Two decisions matter: lazy-load the decoder and process files one at a time.
const MAX_INPUT_BYTES = 25 * 1024 * 1024;
async function processFiles(files: File[]) {
// Reject oversized files up front — HEIC decode is memory-heavy (see below)
const tooBig = files.find((f) => f.size > MAX_INPUT_BYTES);
if (tooBig) return fail(`${tooBig.name} is over 25MB.`);
// Dynamic import: the WASM decoder only downloads when someone actually
// converts — it stays out of the initial page bundle entirely.
const heic2any = (await import("heic2any")).default;
const out = [];
// Sequential, NOT Promise.all — a parallel batch blows the heap (see below)
for (const file of files) {
const blob = await heic2any({
blob: file,
toType: outputFormat, // "image/jpeg" | "image/png"
quality: quality / 100, // slider, defaults to 0.9
});
const finalBlob = Array.isArray(blob) ? blob[0] : blob; // heic2any can return Blob[]
out.push({ name: rename(file), url: URL.createObjectURL(finalBlob) });
}
return out;
}
Three things that tripped me up
1. The first conversion is slow — and that's fine. The libheif WASM module is chunky (over a megabyte). Because it's behind a dynamic import(), it only downloads the first time someone actually converts — it never touches the initial page load — and the browser caches it after that, so every conversion afterward feels instant. Compared to the typical converter site loading 4–6 MB of ad scripts before you can even drop a file, one lazy-loaded module is a good deal. I just made the "first HEIC takes longer (decoder loading)" state explicit in the UI so the first hit doesn't feel broken.
2. Memory, not CPU, is the real ceiling. Decoding HEIC allocates a full RGBA bitmap — width × height × 4 bytes. A 48 MP photo is ~190 MB decoded, before you've even re-encoded it. On desktop, fine. On a mobile Safari tab with a hard memory cap, big files kill the tab. So I cap input at 25 MB per file and run the batch sequentially — the for … await loop above, not Promise.all — so ten files don't allocate ten bitmaps at once.
3. Lossy → lossy needs a quality escape hatch. HEIC → JPG re-encodes already-decoded pixels into another lossy format. At quality: 0.9 it's visually identical for photos; below ~0.8 you start seeing it on smooth gradients (sky, skin). I exposed a quality slider and default it to 90, and offer PNG for anyone who wants zero loss after the decode step.
Why bother when Apple's "Most Compatible" setting exists
You can tell an iPhone to save JPG going forward (Settings → Camera → Formats → Most Compatible), and everyone should. But that does nothing for the thousands of HEICs already in the camera roll — those still need converting, and that's the job this tool does without asking anyone to upload anything.
If you want the deeper non-technical version of the HEIC-on-Windows mess — the black-thumbnail bug, the Microsoft extensions, why some files won't open even after you install everything — I wrote that up here: Why HEIC files won't open on Windows.
And the tool itself, if you want to poke at it (or watch the Network tab stay empty): HEIC to JPG converter.
Takeaway
For a whole class of "convert this file" tools, the server is a liability, not a feature. WASM builds of the underlying C libraries — libheif, libvips, mozjpeg, pdfium — are good enough now that the file never has to leave the device. Faster for the user, zero privacy surface for you, and no infra to run. That's the direction I'm taking the rest of the tools too.
What other file conversions are you still uploading to a server that could run locally? Curious what people are stuck on.
Top comments (0)