DEV Community

glebr2d2
glebr2d2

Posted on

# How I Built a Client-Side HEIC Converter — No Server Required

Every time someone asked me to "just send the photo as JPG," I died a little inside. iPhones have been saving photos as HEIC since iOS 11, and in 2026, the format mismatch is still a daily pain point for millions of people. Most online converters ask you to upload your personal photos to some random server. I wanted to build something better: a converter that runs entirely in your browser, with zero server involvement.

Here's how I built it, what broke along the way, and what I learned about decoding Apple's image format with WebAssembly.

Why Client-Side Matters

The typical file converter architecture is straightforward: user uploads file, server processes it, server returns the result. It works, but it comes with baggage:

  • Privacy: Your photos hit someone else's machine. For personal photos, that's a hard sell.
  • Server costs: Image processing is CPU-intensive. At scale, you're paying real money for compute.
  • Latency: Upload + process + download vs. just... process. Locally.
  • Offline capability: A client-side converter works on a plane, in a coffee shop with bad wifi, anywhere.

The tradeoff is complexity. Browsers weren't designed to decode proprietary image formats. But WebAssembly changed that equation.

How HEIC Decoding Works in the Browser

HEIC (High Efficiency Image Container) is built on top of the HEVC video codec — the same tech used for 4K video. Browsers don't support it natively (except Safari, somewhat). So you need a decoder.

The pipeline looks like this:

HEIC file (ArrayBuffer)
    → libheif (compiled to WASM via Emscripten)
    → Raw pixel data (RGBA)
    → Canvas API (draw pixels)
    → canvas.toBlob() → JPG/PNG Blob
    → Download link
Enter fullscreen mode Exit fullscreen mode

I used the heic-to library, which wraps libheif's WASM build into a clean async API. The core conversion is surprisingly compact:

import HeicTo from 'heic-to';

async function convertHeicToJpg(heicFile, quality = 0.9) {
  // heic-to handles WASM loading, decoding, and Canvas rendering
  const jpgBlob = await HeicTo({
    blob: heicFile,       // File or Blob
    to: 'jpeg',           // target format
    quality: quality       // 0.0 - 1.0
  });

  return jpgBlob;  // standard Blob, ready to download
}
Enter fullscreen mode Exit fullscreen mode

Under the hood, heic-to loads a ~1.5MB WASM binary (libheif), decodes the HEIC container, extracts the HEVC-compressed image data, and renders the raw pixels onto an offscreen canvas. The Canvas API then does the format conversion via toBlob().

For batch conversions with multiple files, I zip the results client-side using JSZip:

import JSZip from 'jszip';

async function batchConvert(files, format, quality) {
  const zip = new JSZip();

  for (const file of files) {
    const blob = await HeicTo({ blob: file, to: format, quality });
    const name = file.name.replace(/\.heic$/i, `.${format === 'jpeg' ? 'jpg' : format}`);
    zip.file(name, blob);
  }

  const zipBlob = await zip.generateAsync({ type: 'blob' });
  // trigger download...
}
Enter fullscreen mode Exit fullscreen mode

Everything stays in browser memory. No temporary files on a server, no cleanup jobs, no S3 buckets.

The Challenges Nobody Warns You About

Color Space Mismatch

This was the most insidious bug. iPhones shoot in Display P3 color space — a wider gamut than the sRGB that JPG typically assumes. When you decode HEIC and render to Canvas, the browser may or may not apply color management depending on the platform.

The result: photos that look subtly different after conversion. Slightly desaturated greens, shifted skin tones. Not wrong enough to notice immediately, but wrong enough to bother a photographer.

My solution was an optional sRGB normalization pass. After initial decoding, I re-draw through a Canvas context to force sRGB interpretation:

if (convertToSrgb) {
  const bitmap = await createImageBitmap(blob);
  const canvas = document.createElement('canvas');
  canvas.width = bitmap.width;
  canvas.height = bitmap.height;
  const ctx = canvas.getContext('2d');
  ctx.drawImage(bitmap, 0, 0);
  blob = await new Promise(resolve =>
    canvas.toBlob(resolve, `image/${format}`, quality)
  );
}
Enter fullscreen mode Exit fullscreen mode

It's not perfect color management, but it handles the 90% case. I exposed it as a toggle so users can decide.

Memory Limits on Large Files

A 48MP iPhone photo in HEIC might be 8-12MB compressed. Decoded to raw RGBA pixels, that's 8064 × 6048 × 4 bytes ≈ 195MB in memory. Multiply that by a batch of 20 photos, and you're pushing browsers to their limits.

I process files sequentially rather than in parallel. It's slower, but it avoids the "Aw, Snap!" crash that Chrome shows when you blow through its memory ceiling. For files above ~80MB, I recommend the Chrome extension, which has slightly more generous resource limits.

Browser Compatibility

The WASM-based decoder works in all modern browsers — Chrome, Firefox, Edge, Safari 15+. But I hit edge cases:

  • Safari on iOS: Intermittent failures with certain HEIC variants that use tiling (multi-image containers). The same files decode fine on macOS Safari.
  • Firefox: Slightly slower WASM execution compared to Chrome's V8 engine, but functionally identical.
  • Older browsers: No WASM = no conversion. I show a clear error rather than failing silently.

The Chrome Extension Angle

After building the web version, packaging it as a Chrome extension was a natural next step. The core conversion logic is identical — same heic-to library, same Canvas pipeline. The extension adds convenience: right-click context menu integration, persistent settings, and one fewer tab to keep open.

The extension runs under Manifest V3 with a service worker background script. One quirk: Chrome extensions have stricter CSP (Content Security Policy) than regular web pages, so all WASM loading has to go through 'self' — no CDN loading. I bundle everything locally.

You can try both the web converter and the extension at convert.rocks — the web version is free, no sign-up, and yes, you can disconnect your internet after loading the page to verify nothing gets uploaded.

What I'd Do Differently

Start with WebP output sooner. JPG and PNG were the obvious first targets, but WebP gives you better compression than JPG with better quality. I added it later using webp-converter-browser, but it requires an extra conversion step (HEIC → PNG → WebP) that I'd architect differently from the start.

Use @jsquash for modern format support. The jSquash project provides modular WASM codecs for AVIF, JXL, and WebP with much smaller bundles (~10KB-2MB per codec vs. monolithic builds). For the next phase, I'm adding AVIF encoding and decoding — it's the next "HEIC moment" as Android and web platforms push adoption.

Consider OffscreenCanvas and Web Workers. Currently, conversion blocks the main thread during the Canvas rendering step. For large files, the UI freezes for 1-2 seconds. Moving the Canvas pipeline to a Web Worker with OffscreenCanvas would keep the UI responsive. It's well-supported now but requires restructuring the conversion flow.

Progressive rendering for batch jobs. Right now, all files convert before any results appear. A streaming approach — showing each converted file as it finishes — would feel much faster even if total time is the same.

The Numbers

The entire converter is ~15KB of application JavaScript plus ~1.5MB of WASM (loaded on demand). No framework, no build step for the web version — just vanilla JS, a CDN-loaded library, and the Canvas API. It loads in under 2 seconds on a 3G connection, and conversion takes 1-3 seconds per photo depending on resolution.

For an image format that causes daily frustration for millions of iPhone users sharing photos with Windows and Android users, a 15KB solution that runs in the browser feels like the right level of complexity.


If you're building browser-based file tools and want to compare notes, find me in the comments. I'm currently working on AVIF and JXL converters using the same client-side architecture.

Top comments (0)