iPhone photos in HEIC format hit JavaScript apps every day — image upload forms, photo editors, social apps. Browsers do not natively decode HEIC, so converting to JPG happens either in JavaScript with a polyfill, on your server with sharp, or on someone else's server via an API.

The right choice depends on where the file lives. Pure browser conversion keeps the photo on the user's device. Server-side gives you faster conversion and EXIF preservation. The API trades a per-call cost for not having to manage either.

Method 1: ChangeThisFile API (anywhere fetch works)

If you can call HTTP, the API works. Free tier gives 1,000 conversions/month.

import fs from "node:fs";

const API_KEY = "sk_test_your_key_here";

async function heicToJpg(heicPath, jpgPath) {
  const buffer = fs.readFileSync(heicPath);
  const form = new FormData();
  form.append("file", new Blob([buffer]), "input.heic");
  form.append("source", "heic");
  form.append("target", "jpg");

  const response = await fetch("https://changethisfile.com/v1/convert", {
    method: "POST",
    headers: { Authorization: `Bearer ${API_KEY}` },
    body: form,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const jpgBuffer = Buffer.from(await response.arrayBuffer());
  fs.writeFileSync(jpgPath, jpgBuffer);
}

await heicToJpg("IMG_1234.HEIC", "IMG_1234.jpg");

For browser uploads:

async function heicToJpgBrowser(file) {
  const form = new FormData();
  form.append("file", file);
  form.append("source", "heic");
  form.append("target", "jpg");

  const response = await fetch("/api/convert-proxy", {  // proxy through your backend
    method: "POST",
    body: form,
  });

  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return URL.createObjectURL(await response.blob());
}

The API preserves EXIF orientation — photos come back oriented correctly without extra rotation logic.

Method 2: heic2any (pure browser, no server)

heic2any is a pure-JS HEIC decoder that runs entirely in the browser. The user's photo never leaves their device. The tradeoff: it is slow (1-3 seconds per typical photo on average hardware) and increases your bundle size by ~700KB.

npm install heic2any
import heic2any from "heic2any";

async function heicToJpg(file) {
  const blob = await heic2any({
    blob: file,
    toType: "image/jpeg",
    quality: 0.92,
  });
  return URL.createObjectURL(blob);  // downloadable URL for the converted JPG
}

// In your file input handler:
const input = document.querySelector("input[type=file]");
input.addEventListener("change", async (e) => {
  const file = e.target.files[0];
  const url = await heicToJpg(file);
  document.querySelector("img").src = url;
});

heic2any is the right choice when:

  • Privacy is the value prop — photos must never leave the user's device
  • You serve a small audience and bundle size matters less than the privacy story
  • You can tolerate 1-3 seconds of conversion latency per photo

For high-volume processing or batch conversion, server-side wins on speed.

Method 3: sharp (Node, native speed)

sharp is the fastest server-side image library in the JS ecosystem. HEIC support requires sharp built with libheif — which the official npm package includes on Linux x64 and Apple Silicon, but not always on other platforms.

npm install sharp
import sharp from "sharp";
import fs from "node:fs/promises";

async function heicToJpg(heicPath, jpgPath, quality = 92) {
  await sharp(heicPath)
    .rotate()  // auto-rotate based on EXIF orientation
    .jpeg({ quality, progressive: true })
    .toFile(jpgPath);
}

await heicToJpg("IMG_1234.HEIC", "IMG_1234.jpg");

The .rotate() call without arguments tells sharp to apply EXIF orientation and bake the rotation into pixels. Without it, JPG viewers that don't read EXIF will show portraits sideways.

Check HEIC support in your sharp install:

import sharp from "sharp";
console.log(sharp.format.heif);  // should show { input: { ... } }

If HEIC support is missing, you'll need to install libheif system-wide and rebuild sharp, or use the API instead.

When to use each

ApproachBest forTradeoff
ChangeThisFile APIProduction SaaS, varied uploads, no infraPer-call cost, network dependency
heic2anyPrivacy-first browser appsSlow, ~700KB bundle increase
sharpHigh-volume Node backendsNative dependency, libheif must be available

For SaaS apps that accept iPhone uploads, the API is the simplest path. For privacy-first apps where photos must stay client-side, heic2any. For high-throughput backends where you control the infrastructure, sharp.

Production tips

  • Detect HEIC by file content, not extension. Some browsers don't preserve the .heic extension on uploads. Check the magic bytes:
async function isHeic(file) {
  const buf = await file.slice(0, 12).arrayBuffer();
  const bytes = new Uint8Array(buf);
  // HEIC files start with 'ftyp' at byte 4, followed by heic/heix/mif1
  const ftyp = String.fromCharCode(...bytes.slice(4, 8));
  if (ftyp !== "ftyp") return false;
  const brand = String.fromCharCode(...bytes.slice(8, 12));
  return ["heic", "heix", "mif1", "msf1"].includes(brand);
}
  • Show progress for heic2any conversions. Users will think the page is frozen otherwise. A simple spinner is enough.
  • Pre-rotate JPG output. Apply EXIF orientation as pixel rotation so older viewers display correctly. sharp's .rotate() does this; the API does it automatically.
  • Live Photos contain a video. The .heic file has the still image; the paired .mov has the live motion. Conversion only handles the still.

For browser-only privacy-first apps, heic2any is the right choice despite the bundle hit. For everything else, the API wins on operational simplicity — no native dependencies, EXIF handled automatically, scales with traffic. Free tier gives 1,000 conversions/month.