Converting HEIC Images to JPEG in the Browser

Sniff the first bytes of the file for an ISO-BMFF ftyp box with a HEIF brand (heic, heix, mif1, msf1), try createImageBitmap(file) first because Safari decodes HEIC natively, fall back to a lazily loaded WebAssembly decoder (such as libheif-js) in other browsers, draw the result to a canvas and upload the canvas as JPEG — never relying on the .heic extension or on file.type, which is often empty.

iPhones save photos as HEIC by default. Chrome and Firefox on desktop cannot display them, many server image libraries need extra codecs to read them, and users on Windows who received a HEIC by AirDrop or email end up uploading files your preview cannot show. Converting in the browser turns every HEIC into a JPEG before it leaves the device, which fixes previews, shrinks nothing important and removes a codec dependency from the backend. This page belongs to mobile and camera capture uploads in upload fundamentals and browser APIs. Detection by content rather than name is the same technique as detecting file type from magic bytes in JavaScript.

When to use this approach

  • Users upload photos from iPhones, directly or via desktop after AirDrop, iCloud or email.
  • You show a preview before upload, or your server pipeline does not have a HEIF decoder installed.
  • You can afford a lazily loaded decoder (around 1–2 MB of WebAssembly) for the minority of browsers that need it.

Prerequisites

  1. A modern browser with createImageBitmap, OffscreenCanvas or <canvas>, and dynamic import().
  2. A WebAssembly HEIF decoder bundled as a separate chunk — libheif-js (the libheif project compiled to WebAssembly) is the common choice; the code below uses its documented API.
  3. A preprocessing step you already run for camera photos, so HEIC becomes one more input to it — see capturing photos with the capture attribute.

Recognising HEIC by its bytes

HEIC is an ISO Base Media File Format container, the same family as MP4. Its first box is ftyp, and bytes 8–11 carry the major brand. Reading twelve bytes is enough to tell a HEIC from a JPEG (which starts FF D8 FF) whatever the file is called.

First twelve bytes of a HEIC file Bytes 0 to 3 hold the box size, bytes 4 to 7 spell ftyp, and bytes 8 to 11 spell the major brand heic. A JPEG instead starts with FF D8 FF. The brand, not the extension or MIME type, identifies the format. bytes 0–11 of IMG_4032.HEIC 00 00 00 18 box size (24) 66 74 79 70 "ftyp" 68 65 69 63 brand "heic" HEIF brands to accept: heic heix hevc hevx mif1 msf1 not HEIC (AVIF): avif avis — same container, AV1 inside JPEG for contrast: FF D8 FF … A HEIC renamed to .jpg by a messaging app still has this header; a .heic that was already converted by iOS on share has FF D8 FF. Only the bytes tell you which you have.
Three fields in twelve bytes identify HEIC reliably; the extension and MIME type identify it only sometimes.

Implementation

const HEIF_BRANDS = new Set(["heic", "heix", "hevc", "hevx", "mif1", "msf1"]);
const MAX_EDGE = 2048;

export async function isHeic(file: Blob): Promise<boolean> {
  const head = new Uint8Array(await file.slice(0, 12).arrayBuffer());
  if (head.length < 12) return false;
  const box = String.fromCharCode(...head.slice(4, 8));
  const brand = String.fromCharCode(...head.slice(8, 12));
  return box === "ftyp" && HEIF_BRANDS.has(brand);
}

/** Native path: Safari (and any browser with a HEIF decoder) decodes directly. */
async function decodeNative(file: Blob): Promise<ImageBitmap | null> {
  try {
    return await createImageBitmap(file, { imageOrientation: "from-image" });
  } catch {
    return null;                                  // "The source image could not be decoded."
  }
}

/** WebAssembly path: loaded only when a HEIC actually needs it. */
async function decodeWasm(file: Blob): Promise<ImageBitmap> {
  const { default: libheif } = await import("libheif-js/wasm-bundle");
  const decoder = new libheif.HeifDecoder();
  const images = decoder.decode(new Uint8Array(await file.arrayBuffer()));
  if (!images.length) throw new Error("HEIC contained no images");
  const img = images[0];                          // the primary image; others are thumbnails/depth
  const width = img.get_width();
  const height = img.get_height();
  const imageData = new ImageData(width, height);
  await new Promise<void>((resolve, reject) => {
    img.display(imageData, (result: ImageData | null) => (result ? resolve() : reject(new Error("HEIF decode failed"))));
  });
  // libheif applies the file's rotation transforms, so the pixels are already upright.
  return createImageBitmap(imageData);
}

export async function heicToJpeg(file: File, quality = 0.88): Promise<File> {
  const bitmap = (await decodeNative(file)) ?? (await decodeWasm(file));
  const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
  const w = Math.round(bitmap.width * scale), h = Math.round(bitmap.height * scale);

  const canvas = new OffscreenCanvas(w, h);
  const ctx = canvas.getContext("2d");
  if (!ctx) throw new Error("2D canvas unavailable");
  ctx.drawImage(bitmap, 0, 0, w, h);
  bitmap.close();

  const blob = await canvas.convertToBlob({ type: "image/jpeg", quality });
  const name = file.name.replace(/\.(heic|heif)$/i, "") + ".jpg";
  return new File([blob], name, { type: "image/jpeg", lastModified: file.lastModified });
}

/** Drop-in for any file input handler. */
export async function prepareForUpload(file: File): Promise<File> {
  return (await isHeic(file)) ? heicToJpeg(file) : file;
}

// Usage
const input = document.querySelector<HTMLInputElement>("#photos")!;
input.addEventListener("change", async () => {
  for (const f of Array.from(input.files ?? [])) {
    const ready = await prepareForUpload(f);      // one at a time: decodes are memory-heavy
    console.log(f.name, f.size, "→", ready.name, ready.size);
  }
});

Line-by-line on the decisions that matter

  • Sniffing twelve bytes with file.slice(0, 12). Reading the whole file to check its type would load megabytes for a yes/no answer. slice reads only what you ask for, as explained in slicing large files with Blob.slice.
  • Native first. Safari on macOS and iOS decodes HEIC through the OS; it is faster than any WebAssembly decoder and uses hardware. createImageBitmap rejecting is the reliable signal that the browser cannot.
  • Dynamic import() of the decoder. The WebAssembly module is large. Loading it only when a HEIC appears in a non-Safari browser keeps it out of every other page load.
  • images[0] — the primary image. A HEIC is a container that may hold the photo, a thumbnail, a depth map and a Live Photo’s frames. The decoder lists the top-level images; the first is the primary.
  • Orientation. HEIC stores rotation as an image transform (irot) rather than an EXIF tag. The native path applies it via imageOrientation: "from-image", and libheif applies transforms while decoding; do not rotate again yourself.
  • Downscale before encoding. A 12-megapixel HEIC decodes to about 48 MB of pixels; many products never need more than 2048 pixels. The resize step is the same one used for every camera photo.

Which path runs where

Decode path and time per browser for a 12 megapixel HEIC Safari on iOS and macOS decodes natively in about 120 milliseconds. Chrome and Firefox on desktop fail the native decode and use the WebAssembly decoder in about 1.4 seconds, plus a one-time load of the decoder. Chrome on Android also uses WebAssembly, taking about 3 seconds. 12 MP HEIC → JPEG, decode + encode time Safari (native) ≈ 0.12 s desktop Chrome (wasm) ≈ 1.4 s + decoder load Android Chrome (wasm) ≈ 3 s Show a per-photo "converting" state; for batches, convert while earlier photos upload. Consider a Web Worker for the wasm path so the page stays responsive during a batch.
Most HEIC files come from iPhones, where the fast native path runs; the slow path is for shared files on other platforms.

Configuration gotchas

DOMException: The source image could not be decoded. Expected in browsers without HEIF support — it is the signal to use the WebAssembly path, not a bug. Make sure the catch covers createImageBitmap specifically, not the whole conversion.

WebAssembly.instantiate(): Out of memory. Decoding very large HEIF files (48-megapixel ProRAW-derived HEICs, panoramas) in a 32-bit WebAssembly heap can fail on phones. Check dimensions before decoding if the decoder exposes them, and fall back to uploading the original for the server to convert.

Live Photos upload as a still only. The motion part of a Live Photo is a separate .mov in the photo library; selecting the photo in a file input returns only the HEIC (or a JPEG). If you need the motion, users must select the video too.

Colours shift after conversion. iPhone HEICs are Display P3. Canvas in most browsers works in sRGB and converts on draw; the result is correct but slightly less saturated on wide-gamut screens. If colour fidelity matters, request colorSpace: "display-p3" on the canvas context where supported and embed nothing — or leave conversion to the server with a colour-managed pipeline.

Running the fallback decoder off the main thread

On desktop Chrome, decoding a HEIC in WebAssembly takes over a second; on a phone, several. Run on the main thread, that freezes scrolling, typing and progress bars for the whole conversion. Move the WebAssembly path into a dedicated worker: post the File in, receive an ImageBitmap or ImageData back (both are transferable, so nothing is copied), and draw it on the page or in an OffscreenCanvas inside the same worker.

The worker also gives you a natural place to serialise work. Keep one worker and a queue of files; convert one, post it back, take the next. Parallel conversions do not finish sooner on a phone — they compete for the same cores and multiply peak memory — and a queue makes it easy to show “converting 3 of 8” instead of an unexplained pause.

Load the decoder module inside the worker, not the page, so the page bundle never contains it. With a module worker (new Worker(new URL("./heic-worker.ts", import.meta.url), { type: "module" })) the dynamic import() in the worker fetches the WebAssembly only the first time a HEIC needs the fallback, and the browser caches it for later visits.

Convert on the client or on the server?

Client conversion helps every downstream consumer and your preview; server conversion keeps full quality and avoids shipping a decoder. Many products do both: convert in the browser for preview and a fast first upload, and keep the original HEIC for archival when users choose “original quality”.

Client versus server HEIC conversion Client-side conversion gives instant previews, smaller uploads and no server codec, at the cost of a large decoder download and lower fidelity. Server-side conversion keeps the original and full colour but needs a HEIF-capable library such as libvips with libheif, and the client cannot preview the file. Where to convert in the browser preview works everywhere upload is JPEG, often smaller server needs no HEIF codec 1–2 MB decoder on non-Safari on the server original kept, full P3 colour no client code at all sharp + libheif does it no preview before upload
Convert in the browser for the experience, and keep the original on the server when quality or archival matters.

Verification

import { strict as assert } from "node:assert";

// In a browser test runner (Playwright/Vitest browser mode) with fixture files:
const heic = new File([await (await fetch("/fixtures/iphone-portrait.heic")).blob()], "IMG_4032.HEIC");
const renamed = new File([await heic.arrayBuffer()], "photo.jpg", { type: "image/jpeg" });
const jpeg = new File([await (await fetch("/fixtures/plain.jpg")).blob()], "plain.jpg");

assert.equal(await isHeic(heic), true);
assert.equal(await isHeic(renamed), true, "detected by bytes despite .jpg name");
assert.equal(await isHeic(jpeg), false);

const out = await heicToJpeg(heic);
const bmp = await createImageBitmap(out);
assert.equal(out.type, "image/jpeg");
assert.ok(bmp.height > bmp.width, "portrait stays portrait");

Frequently Asked Questions

Doesn’t iOS convert HEIC to JPEG automatically on upload?

Often, yes: when a web page’s file input accepts image/* and not HEIC explicitly, iOS Safari may transcode to JPEG before handing over the file. It does not always happen — it depends on the accept value, the source (Files app versus Photos), and iOS version — and it never happens for HEIC files that reach a desktop by other routes. Detect by bytes and convert when needed.

Should accept list .heic?

If you convert in the browser, listing image/heic,image/heif lets iOS hand over the original rather than its own conversion, which gives you control of quality. If you do not convert, leaving HEIC out encourages iOS to send JPEG. Either way, the server must still validate bytes, as restricting uploads with the accept attribute explains.

What about AVIF uploads?

AVIF shares the container and would pass a naive ftyp check — that is why the brand list above excludes avif. Chrome, Firefox and Safari 16+ decode AVIF natively, so it can go straight through the normal canvas path.