DEV Community

Handling HEIC uploads from iPhones in 2026: what breaks and how to fix it

A photo that looks fine on an iPhone can still fail the moment it hits your upload form. The preview is blank, the server rejects the file, or a <canvas> thumbnail throws, and the report is usually "it is just a photo."

It is often a HEIC file: the default iPhone still-image format, and still something most browsers will not display. I built heic.now because I kept hitting that failure in CMS uploads and client hand-offs, and I am the site's maker, so I will be explicit about that. Here is what actually breaks in 2026, and how to handle it.

Why it works on the phone and fails on a laptop

Since iOS 11, iPhones set to High Efficiency (Settings → Camera → Formats) write stills as HEIC: a HEIF container with HEVC-compressed image data. The files are smaller than a comparable JPEG, which is why Apple made them the default. They are not a JPEG with an unusual extension.

Uploads from the iPhone itself often succeed anyway. When someone picks a photo from the iOS Photo Library in a web <input type="file">, iOS commonly transcodes it to JPEG before your page sees it - in Safari, Chrome, Firefox, and Edge on iOS, even if accept lists image/heic. A test pass on your own iPhone does not prove the server can read HEIC.

The original file usually arrives intact when it is:

  • AirDropped to a Mac,
  • synced to iCloud and uploaded from Windows,
  • picked from the Files app as a real .heic,
  • or attached from Finder.

On macOS, Safari will often keep the HEIC if accept allows it.

Browser support is still Safari-only

Display support and upload support are not the same thing. As of November 2026, Can I Use puts HEIF/HEIC at about 15% of global usage. Safari has displayed it since version 17 on macOS and iOS. Chrome, Edge, Firefox, and the major Android browsers still do not.

That is not a temporary gap. HEIC is built on HEVC, which is patent-encumbered and awkward to license in a browser. AVIF uses the same HEIF container with AV1 instead, and that is the format browsers actually shipped.

Accept HEIC on the way in if you need to, but do not serve it as a page asset, and do not assume <img>, createImageBitmap(), or canvas.drawImage() can preview it. A URL.createObjectURL(file) thumbnail works in current Safari and fails in Chrome and Firefox: the browser has no decoder.

Detect the file you actually received

Do not trust file.type alone. Some platforms leave it blank for HEIC, or send application/octet-stream. accept is only a hint for the picker, so include both MIME types and extensions:

<input type="file" accept="image/heic,image/heif,.heic,.heif,image/jpeg,image/png,image/webp" multiple>

On macOS Safari, that list matters. If image/heic or image/* is allowed, Safari will often hand you the original HEIC. If you only allow image/jpeg, it may transcode first. On iOS Photo Library picks, you will still usually get a JPEG. Validate on the server either way.

Check the MIME type, the extension, and the file signature. HEIF files start with an ftyp box; the brand is usually heic, heix, hevc, or hevx. Treat mif1 with care: it is a generic HEIF brand and also appears on some AVIF files, so read the compatible brands rather than matching mif1 alone.

const HEIC_MIMES = new Set([
  "image/heic",
  "image/heif",
  "image/heic-sequence",
  "image/heif-sequence",
]);

const HEIC_EXTS = new Set(["heic", "heif", "heics"]);

const HEIC_BRANDS = new Set([
  "heic",
  "heix",
  "hevc",
  "hevx",
  "heim",
  "heis",
  "hevm",
  "hevs",
]);

function ftypBrands(buf) {
  if (buf.length < 12 || buf.toString("ascii", 4, 8) !== "ftyp") return [];
  const end = Math.min(buf.readUInt32BE(0), buf.length);
  const brands = [buf.toString("ascii", 8, 12)];
  for (let i = 16; i + 4 <= end; i += 4) {
    brands.push(buf.toString("ascii", i, i + 4));
  }
  return brands;
}

function isHeicUpload({ name = "", type = "", buffer }) {
  if (HEIC_MIMES.has(String(type).toLowerCase())) return true;
  const ext = name.split(".").pop()?.toLowerCase();
  if (HEIC_EXTS.has(ext)) return true;
  return ftypBrands(buffer).some((b) => HEIC_BRANDS.has(b));
}

Once you know it is HEIC, convert it to JPEG, WebP, or AVIF before you preview, store, or serve it.

Do not expect prebuilt sharp to decode it

This is the most common Node.js surprise. sharp is the right tool for resizing and encoding, but the prebuilt libvips cannot decode HEVC. It can often read the container - metadata() may report heif, dimensions, and compression: hevc - and then fail when you convert, with an error along the lines of "Support for this compression format has not been built in: HEVC."

You have two solid options.

Decode with libheif-js or heic-convert, then hand pixels to sharp. heic-convert wraps libheif-js and needs no native toolchain:

import convert from "heic-convert";
import sharp from "sharp";

export async function heicToWebImage(heicBuffer) {
  const jpeg = await convert({
    buffer: heicBuffer,
    format: "JPEG",
    quality: 0.9,
  });
  return sharp(Buffer.from(jpeg))
    .jpeg({ quality: 85, mozjpeg: true })
    .toBuffer();
}

If you want more control, decode with libheif-js to RGBA and pass that buffer into sharp. In a browser tab, the same WASM decoder can run in a Web Worker; heic2any is a thinner wrapper for that case. Client-side conversion is fine for a trusted preview of a few files, not for a large, untrusted upload pipeline.

Build libvips with libheif, libde265, and (if you also encode HEIC) x265, then point sharp at that. The official sharp docs describe this. It is the right move for a high-volume service, and more work than a WASM decoder.

ImageMagick with a libheif HEVC plugin, or FFmpeg 8.1+, can do the same job in other stacks. Older FFmpeg releases may reject the file, ignore rotation, or decode only one tile of a tiled iPhone photo. Test with a real iPhone .heic, not a JPEG you renamed, and not only a shot in one orientation.

Orientation and privacy metadata

iPhone photos are often stored in a different pixel orientation than they are meant to be shown. In JPEG, that usually lives in EXIF Orientation, and sharp's rotate() / autoOrient() will fix it. HEIF is different. The container has its own rotation and mirror properties (irot, imir), and the HEIF spec does not treat EXIF Orientation as the thing that controls presentation. libheif typically applies the container transform during decode, so the pixels you get are already upright. Applying EXIF orientation on top of that is how you get a photo on its side.

Strip location data from anything that will be public. A camera-roll HEIC can carry GPS coordinates, a timestamp, the device model, and Apple maker notes. A Live Photo is a still HEIC plus a short MOV, which can carry its own metadata; if you only clean the still, the clip can still leak a location. Write converted output without GPS unless you have a reason to keep it.

Burst or multi-image HEICs hold more than one still, so if you only decode the primary item you may silently drop the rest. Confirm the preview in Chrome, not only in Safari, and confirm a desktop upload, not only an iOS Photo Library pick.

If you only need to convert a handful of iPhone photos and you are not building a pipeline, a browser converter is enough. heic.now is the one I use for that job: HEIC to JPG, PNG, WebP, AVIF, PDF, GIF, or TIFF, with optional resize and metadata removal, and no account required. Guests get 10 conversions a day; uploads travel over HTTPS, conversion runs on the server, and guest files are deleted within 24 hours.

For a product, make the conversion part of the upload path so the first file a Chrome user sees is already a format their browser can display.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.