DEV Community

Cover image for Why HEIC Breaks Websites — and How I Built a Browser-Only Converter to Fix It
Roman Popovych
Roman Popovych

Posted on

Why HEIC Breaks Websites — and How I Built a Browser-Only Converter to Fix It

My girlfriend was building a client site on Weblium. The client sent 200+ photos in HEIC format, kept adding more, swapping some out. Standard client behavior.

The problem: every single photo needed to be converted to WebP before it could go on the site. And the workflow she ended up with was painful — drag into Figma, export as PNG (because Figma can't export WebP natively), then convert PNG → WebP using an online tool. Two unnecessary steps for every photo. Figma plugins exist that handle this, but the good ones are paid or rate-limited, and using Figma as an image converter is overkill by definition.

I watched this happen and added two tools to my dev tools site: HEIC to JPG and HEIC to WebP. Drop files, batch convert, download ZIP. One step, zero uploads.

This post is about why HEIC is a problem for the web, how browser-based HEIC conversion actually works under the hood, and the specific decisions I made building it.


Why HEIC Exists and Why It Breaks Everything Outside Apple

Apple switched iPhones to HEIC by default with iOS 11 (2017). The motivation was pure storage efficiency: HEIC uses HEVC (H.265) as its image codec — the same codec used for 4K video streaming — inside an ISOBMFF container (the same container format as MP4).

The result is roughly 50% better compression than JPEG at the same perceptual quality. A 12MP iPhone photo that would be 5–8 MB as JPEG comes out at 2–4 MB as HEIC. For a phone with 64 GB of storage shooting thousands of photos, this matters enormously.

The problem is HEVC patent licensing. The codec is encumbered by patents from multiple pools (MPEG LA, HEVC Advance, Velos Media). Browser vendors haven't shipped a licensed HEVC decoder because the royalty structure is complex and contested. Safari gets away with it by delegating to the OS — macOS and iOS already have a licensed HEVC decoder for video playback, so Safari reuses it for images too. Chrome and Firefox don't have this path.

Browser HEIC support as of 2025

Browser HEIC support Notes
Safari (macOS / iOS) Native Delegates to OS HEVC decoder
Chrome None No licensed HEVC decoder
Firefox None No licensed HEVC decoder
Edge None Same engine as Chrome
Windows Photos Paid codec Requires Microsoft Store purchase

So if a client sends you HEIC photos and you upload them directly to a website, Chrome users (65%+ of global traffic) see nothing — the <img> tag simply fails to render.


The File Size Problem

Even if browser support weren't an issue, raw HEIC files are too heavy for web use.

2 MB per photo sounds reasonable until you have a gallery. A product page with 10 photos = 20 MB of images. Google's Core Web Vitals guidelines recommend keeping total page weight under 1–2 MB for fast LCP. A single unoptimized HEIC photo blows that budget.

WebP solves both problems: it's universally supported in modern browsers (97%+ of traffic including Safari 14+) and its compression is competitive with HEIC.

Format comparison for web delivery

Format File size (12MP photo) Browser support Transparency Best for
HEIC 2–4 MB Safari only Yes iPhone storage
JPEG 4–8 MB Universal No Legacy compatibility
PNG 12–25 MB Universal Yes Lossless / editing
WebP 1–3 MB 97%+ (Safari 14+) Yes Web delivery
AVIF 1–2 MB 90%+ Yes Next-gen web

WebP at quality 0.80 produces files 1–2 MB from a typical 12MP iPhone HEIC source — a 4–8× reduction compared to PNG and 50–75% smaller than the equivalent JPEG, with no visible quality difference on screen.


How Browser-Based HEIC Conversion Actually Works

The interesting constraint: I wanted zero uploads. Files never leave the user's device. This is a genuine privacy benefit when converting personal photos, medical images, or client work — but it means all decoding has to happen client-side.

HEIC decoding in a browser is non-trivial because, as established, Chrome and Firefox have no native HEIC support. There are two paths:

Path 1: Safari — native OS decoder via createImageBitmap

Safari can decode HEIC natively because it delegates to the macOS/iOS system codec. The browser exposes this through the standard createImageBitmap API:

const bitmap = await createImageBitmap(heicFile)
const canvas = document.createElement('canvas')
canvas.width = bitmap.width
canvas.height = bitmap.height
const ctx = canvas.getContext('2d')

// HEIC → JPEG: fill white background (HEIC supports transparency, JPEG doesn't)
if (outputMime === 'image/jpeg') {
  ctx.fillStyle = '#ffffff'
  ctx.fillRect(0, 0, canvas.width, canvas.height)
}

ctx.drawImage(bitmap, 0, 0)
bitmap.close()

const blob = await new Promise((resolve, reject) =>
  canvas.toBlob(
    b => (b ? resolve(b) : reject(new Error('toBlob failed'))),
    outputMime,
    0.8,
  )
)
Enter fullscreen mode Exit fullscreen mode

Zero library cost, zero network requests, instant. Safari users get native performance.

Path 2: Chrome / Firefox — WebAssembly via heic-to

For Chrome and Firefox, I use heic-to — a library that bundles a WebAssembly build of libheif, which includes the libde265 software HEVC decoder.

The key decision here is lazy loading. The WASM bundle is ~1.2 MB. Loading it on every page visit — even for users who never drop a HEIC file — would be wasteful. Instead, I import it dynamically only when a HEIC file is actually detected:

const { heicTo } = await import('heic-to') // ~1.2 MB WASM, first use only
const blob = await heicTo({ blob: heicFile, type: outputMime, quality: 0.8 })
Enter fullscreen mode Exit fullscreen mode

After the first conversion, the module is cached by the browser's module system. Subsequent conversions in the same session are instant.

The full conversion function

Putting it together — try native first, fall back to WASM:

const HEIC_QUALITY = 0.8

export async function convertHeicImage(file, mimeType) {
  try {
    // Safari: native path via OS HEVC decoder
    const bitmap = await createImageBitmap(file)
    const canvas = document.createElement('canvas')
    canvas.width = bitmap.width
    canvas.height = bitmap.height
    const ctx = canvas.getContext('2d')
    if (mimeType === 'image/jpeg') {
      ctx.fillStyle = '#ffffff'
      ctx.fillRect(0, 0, canvas.width, canvas.height)
    }
    ctx.drawImage(bitmap, 0, 0)
    bitmap.close()
    return new Promise((resolve, reject) =>
      canvas.toBlob(
        blob => (blob ? resolve(blob) : reject(new Error('Conversion failed'))),
        mimeType,
        HEIC_QUALITY,
      )
    )
  } catch {
    // Chrome/Firefox: lazy-load WASM decoder on first HEIC file
    const { heicTo } = await import('heic-to')
    return heicTo({ blob: file, type: mimeType, quality: HEIC_QUALITY })
  }
}
Enter fullscreen mode Exit fullscreen mode

createImageBitmap throws on Chrome/Firefox when it encounters a HEIC file it can't decode — the catch block is the branch, not error handling for Safari failures.


Detecting HEIC Files Reliably

HEIC detection can't rely on MIME type alone. When users drag files from Windows Explorer, the browser often reports file.type as an empty string for HEIC because Windows doesn't register the MIME type without the paid codec. So the check needs to cover both:

const isHeic =
  file.type === 'image/heic' ||
  file.type === 'image/heif' ||
  file.name.toLowerCase().endsWith('.heic') ||
  file.name.toLowerCase().endsWith('.heif')
Enter fullscreen mode Exit fullscreen mode

For the file input's accept attribute, I also include the extensions:

<input accept="image/heic,image/heif,.heic,.heif" />
Enter fullscreen mode Exit fullscreen mode

Without the .heic,.heif fallback, the file picker on Windows hides HEIC files entirely because the OS doesn't know their MIME type.


Why Not PNG?

Initially I considered adding HEIC to PNG as well, but removed it. The math is ugly:

Conversion Typical output (12MP photo) vs HEIC source
HEIC → JPEG (q=0.80) ~1.5 MB similar size
HEIC → WebP (q=0.80) ~1 MB smaller
HEIC → PNG ~15 MB 10× larger

PNG is lossless — it stores every pixel precisely. A 12MP photo at 4 bytes/pixel = 144 MB raw data, which PNG's DEFLATE compression reduces to ~12–20 MB. HEVC achieves 2–4 MB on the same data by exploiting spatial and temporal redundancy across tiles. The difference is fundamental, not a tuning problem.

PNG makes sense as an intermediate format when you need to edit in Photoshop without introducing further compression artifacts. It makes no sense for web delivery. Keeping only JPG and WebP as outputs makes the tool's use case unambiguous.


One-Step vs Two-Step Conversion

A common mistake when converting HEIC for the web is going HEIC → JPEG → WebP. This applies lossy compression twice:

  1. HEIC decode → JPEG encode (introduces DCT artifacts)
  2. JPEG decode → WebP encode (compresses already-degraded data)

Converting HEIC → WebP directly decodes HEIC to a raw pixel buffer (lossless HEVC decode), then passes that buffer to the WebP encoder. Only one lossy step occurs — at the final encode — operating on full-quality pixel data from the HEIC source.

Two-step:  HEIC → (lossy) → JPEG → (lossy) → WebP   quality degrades twice
One-step:  HEIC → (lossless decode) → pixels → (lossy) → WebP
Enter fullscreen mode Exit fullscreen mode

The Batch + ZIP Flow

For 200+ photos, converting one at a time isn't practical. The tool processes files concurrently and bundles results into a ZIP using JSZip:

async function buildZip(files) {
  const zip = new JSZip()
  for (const f of files) {
    if (f.resultBlob) {
      zip.file(f.name, f.resultBlob)
    }
  }
  return zip.generateAsync({ type: 'blob' })
}
Enter fullscreen mode Exit fullscreen mode

The ZIP is generated entirely in memory and downloaded via a Blob URL — no server, no upload, no temp files anywhere outside the user's browser tab.


Stack and Constraints

The entire site is a static React app deployed on Vercel. No backend, no API routes, no file uploads. Every tool runs exclusively in the browser using:

  • Canvas API — image encode/decode and format conversion
  • File API — reading dropped or selected files
  • Blob URL API — in-memory file handling and downloads
  • WebAssembly (heic-to) — HEVC software decode for Chrome/Firefox, lazy-loaded

The HEIC conversion is the only tool on the site that loads external library code at runtime, and only when a HEIC file is actually dropped. Every other converter uses only browser-native APIs.


Try It

Both support batch conversion and ZIP download. Source is vanilla React — no framework magic, just Canvas API and a WASM fallback.

If you've built something similar or have a better approach for client-side HEIC handling, I'd be curious to hear it in the comments.

Top comments (1)

Collapse
 
r0men_ profile image
Roman

real bro, nice post💘❤️❤️❤️😍😍😍😮😮😊😊😘😘