DEV Community

Cover image for I built a JS image compressor that actually handles iPhone HEIC photos
PixSqueeze
PixSqueeze

Posted on

I built a JS image compressor that actually handles iPhone HEIC photos

If you've ever built a file upload feature, you've probably hit this: a user uploads a photo from their iPhone, and your app breaks. No preview, no compression, just a silent failure — because the file is .heic and browsers can't read it.

HEIC is Apple's default photo format since iOS 11. Every iPhone photo taken today is HEIC. And virtually every JS image compression library just ignores the problem.

I spent a weekend building PixSqueeze to fix that.

The problem with HEIC in the browser

Browsers use the <canvas> API to compress images. You draw the image onto a canvas, call canvas.toBlob(), and get a compressed file back. Clean, client-side, zero server cost.

The problem: canvas.drawImage() only works if the browser can decode the image first. And no major browser can decode HEIC natively — not Chrome, not Firefox, not even Safari on macOS (Safari on iOS can, but that doesn't help your web app).

So when a user picks a HEIC file, your image element fires onerror, your canvas stays blank, and your compression pipeline silently does nothing.

The solution: server-side conversion, then client-side compression

PixSqueeze handles this in two stages:

Stage 1 — Server converts the format
HEIC (and TIFF, camera RAW) files get sent to a small Express server. The server uses heic-convert running in a dedicated worker thread to convert to JPEG. Worker threads matter here — HEIC decoding is CPU-intensive WASM work, and running it on the main event loop would block every other request.

Stage 2 — Client compresses the result
The converted JPEG comes back to the browser, where the normal canvas-based compression pipeline takes over. Quality, resize, watermark hooks — all the usual options.

The detection is important too. You can't just check file.type — iOS often sets HEIC files with an empty MIME type. PixSqueeze checks the ISO Base Media File Format magic bytes directly:

async function isHeicFile(file) {
  const buffer = await file.slice(0, 12).arrayBuffer();
  const bytes = new Uint8Array(buffer);
  // ftyp box at offset 4, brand starts at offset 8
  const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]);
  return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand.toLowerCase());
}
Enter fullscreen mode Exit fullscreen mode

Getting started

Install it:

npm install pixsqueeze
Enter fullscreen mode Exit fullscreen mode

Basic usage — compress any image before upload:

import PixSqueeze from "pixsqueeze";

new PixSqueeze(file, {
  quality: 0.6,
  success: (result) => uploadToServer(result),
  error: (err) => console.error(err.message),
});
Enter fullscreen mode Exit fullscreen mode

Full HEIC pipeline — detect, convert on server, compress:

import PixSqueeze from "pixsqueeze";

async function handleFile(file) {
  // Convert HEIC on server if needed
  const resolvedFile = (await isHeicFile(file))
    ? await convertOnServer(file, "/api/convert/heic")
    : file;

  // Compress client-side as usual
  new PixSqueeze(resolvedFile, {
    quality: 0.6,
    success: (result) => console.log("Ready:", result),
    error: (err) => console.error(err.message),
  });
}

async function convertOnServer(file, endpoint) {
  const formData = new FormData();
  formData.append("file", file);
  const res = await fetch(endpoint, { method: "POST", body: formData });
  const blob = await res.blob();
  return new File([blob], file.name.replace(/\.\w+$/, ".jpg"), { type: "image/jpeg" });
}
Enter fullscreen mode Exit fullscreen mode

The bundled server (npm run server) exposes three endpoints:

  • POST /api/convert/heic — HEIC/HEIF → JPEG
  • POST /api/convert/tiff — TIFF → JPEG (including multi-page)
  • POST /api/convert/raw — Camera RAW → JPEG (.cr2, .nef, .arw, .dng, and more)

Other things it can do

Resize while compressing:

new PixSqueeze(file, {
  maxWidth: 1280,
  maxHeight: 1280,
  quality: 0.7,
  success: (result) => console.log(result),
});
Enter fullscreen mode Exit fullscreen mode

Add a watermark:

new PixSqueeze(file, {
  drew(context, canvas) {
    context.font = "bold 2rem sans-serif";
    context.fillStyle = "rgba(255,255,255,0.6)";
    context.fillText("© Your Brand", 20, canvas.height - 20);
  },
  success: (result) => console.log(result),
});
Enter fullscreen mode Exit fullscreen mode

Convert to grayscale:

new PixSqueeze(file, {
  beforeDraw(context) {
    context.filter = "grayscale(100%)";
  },
  success: (result) => console.log(result),
});
Enter fullscreen mode Exit fullscreen mode

Try the live demo

There's a playground at avlisodraude.github.io/compressme — drop any image (including HEIC if you have one) and see compression live.

Links

Single-image compression is free forever. Batch processing is coming — follow @pixsqueeze for updates.


Built with heic-convert, sharp, and the browser Canvas API.

Top comments (0)