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,
)
)
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 })
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 })
}
}
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')
For the file input's accept attribute, I also include the extensions:
<input accept="image/heic,image/heif,.heic,.heif" />
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:
- HEIC decode → JPEG encode (introduces DCT artifacts)
- 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
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' })
}
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
- HEIC to WebP — smallest files, best for web
- HEIC to JPG — maximum compatibility
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)
real bro, nice post💘❤️❤️❤️😍😍😍😮😮😊😊😘😘