DEV Community

James Zhang
James Zhang

Posted on

Decoding iPhone HEIC images in the browser with WebAssembly (no server, no uploads)

Every "free HEIC converter" website I found had the same privacy model: upload your photos to my server, wait, download the result. Family photos. Screenshots. Whatever was in your camera roll. I didn't want my files on someone else's disk, and I doubted other people wanted theirs on mine either.

So I spent a while teaching the browser to do it locally instead. This post is about how the decoding actually works — the WASM integration, the Content Security Policy dance, and the bundler quirks nobody warns you about.

The result is a batch HEIC converter that runs entirely client-side: snappykit.site/heic-converter. Up to 20 images at once, converts to JPG/PNG/WebP/AVIF, downloads as a ZIP. No account, no upload.

The decoder

The heavy lifting comes from libheif compiled to WebAssembly (libheif-js/wasm-bundle, which wraps @seven332/libheif-wasm). The API is refreshingly small:

interface HeifImage {
  get_width(): number;
  get_height(): number;
  display(data: DisplayData, callback: (d: DisplayData | null) => void): void;
  free(): void;
}

interface HeifLibrary {
  HeifDecoder: new () => { decode(buffer: ArrayBuffer): HeifImage[] };
}
Enter fullscreen mode Exit fullscreen mode

The pipeline for one file: read the File into an ArrayBuffer, hand it to HeifDecoder.decode(), call display() to get RGBA pixels, paint them onto a canvas, then canvas.toBlob() for the output format. Standard Canvas work — libheif only does the decode step.

Two details matter:

1. Lazy-load the WASM module and cache the promise. The bundle is large, and 95% of the site doesn't need it:

let heifLoaderPromise: Promise<HeifLibrary> | undefined;

function loadHeifWasm() {
  heifLoaderPromise ??= import("libheif-js/wasm-bundle").then(
    (m: HeifModule) => m.default ?? m
  );
  return heifLoaderPromise;
}
Enter fullscreen mode Exit fullscreen mode

The ??= cache means concurrent conversions await the same initialization instead of racing to compile the WASM twice.

2. Always call free(). The HeifImage holds WASM memory. Skipping the free call leaks until the tab dies — noticeable when someone drops 20 iPhone photos at once.

The CSP dance

My site runs a strict Content Security Policy. WASM compilation needs wasm-unsafe-eval — fine, one directive. But libheif's embind bindings generate function stubs with eval(), which needs full unsafe-eval. I didn't want to open that door site-wide for one tool.

The fix was per-route CSP in Next.js config:

// next.config.mjs
const baseCsp = "script-src 'self' 'wasm-unsafe-eval' ...";
const heicCsp = baseCsp.replace(
  "script-src 'self' 'wasm-unsafe-eval'",
  "script-src 'self' 'wasm-unsafe-eval' 'unsafe-eval'"
);

// headers() returns baseCsp for /* and heicCsp for /heic-converter
Enter fullscreen mode Exit fullscreen mode

One route gets the relaxation; the other 60 pages keep the strict policy.

The bundler quirk

The WASM package declares ESM but ships CommonJS files, so webpack's static analysis chokes on require(). The fix is telling webpack to treat those files as javascript/auto:

config.module.rules.push({
  test: /[\\/]node_modules[\\/]@seven332[\\/]libheif-wasm[\\/].*\.js$/,
  type: "javascript/auto"
});
Enter fullscreen mode Exit fullscreen mode

If your build fails with "Critical dependency: require function is used in a way in which dependencies cannot be statically extracted" — this is that.

Guardrails

Decoding untrusted files means limits: anything above 100 megapixels or 16,000 pixels per side gets rejected before the pixel work starts, and decode failures map to a friendly "this file may be corrupted or use an unsupported codec" instead of a raw embind stack trace.

The part I'm proudest of: the smoke suite literally asserts that no image-upload requests and no external-origin requests are ever made. "Your photos never leave your device" isn't a marketing claim here — it's a failing test if it breaks.

Try it

The converter is free, no account: snappykit.site/heic-converter. It's one of 40+ image tools (compression, conversion, resize, filters, EXIF removal) that all run locally.

Happy to answer questions about the embind/CSP setup, the batch pipeline, or the smoke-test architecture in the comments.

Top comments (0)