I built a JS image compressor that actually handles iPhone HEIC photos
DEV Community Grade 8 4d ago

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 ()); } Getting started Install it: npm install pixsqueeze 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 ), }); 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 " }); } 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 ), }); 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 ), }); Convert to grayscale: new PixSqueeze ( file , { beforeDraw ( context ) { context . filter = " grayscale(100%) " ; }, success : ( result ) => console . log ( result ), }); 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 npm: npmjs.com/package/pixsqueeze GitHub: github.com/avlisodraude/compressme Demo: avlisodraude.github.io/compressme Single-image compression is free forever. Batch processing is coming — follow @pixsqueeze for updates. Built with heic-convert , sharp , and the browser Canvas API.

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 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()); } Getting started Install it: npm install pixsqueeze 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), }); 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" }); } 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), }); 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), }); Convert to grayscale: new PixSqueeze(file, { beforeDraw(context) { context.filter = "grayscale(100%)"; }, success: (result) => console.log(result), }); 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 - npm: npmjs.com/package/pixsqueeze - GitHub: github.com/avlisodraude/compressme - Demo: avlisodraude.github.io/compressme 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)

Comments

No comments yet. Start the discussion.