Prebuilt sharp cannot decode iPhone HEIC photos. It reads the header, so metadata() succeeds, and then toBuffer() fails with an error that does not mention HEVC. Route HEVC files to a WASM decoder such as heic-decode, run it in a worker thread, and hand the raw pixels back to sharp.
What does sharp actually support?
The sharp install docs list what the prebuilt binaries support: JPEG, PNG, Ultra HDR, WebP, AVIF, TIFF, GIF and SVG input. HEIC is not on the list. The output docs say HEVC-compressed HEIF needs a globally installed libvips built with libheif, libde265 and x265.
The confusing part is that AVIF and HEIC share a container (HEIF). sharp parses the container either way, so it can tell you a lot about a file it cannot decode.
What happens when you try?
I tried two HEIC files: a 2268 × 4032 iPhone photo (1,956,001 bytes) and an 800 × 600 test photo (199,693 bytes).
import sharp from 'sharp';
import { readFile } from 'node:fs/promises';
for (const f of ['iphone.heic', 'photo.heic']) {
const buf = await readFile(f);
const major = buf.toString('latin1', 8, 12); // ftyp major brand
const m = await sharp(buf).metadata();
console.log(f, buf.length, 'bytes | brand', major, '| metadata:', m.format, m.compression, `${m.width}x${m.height}`);
try {
await sharp(buf).jpeg().toBuffer();
console.log(' decode OK');
} catch (e) {
console.log(' decode FAILED:', e.message.split('\n')[0]);
}
}
iphone.heic 1956001 bytes | brand heic | metadata: heif hevc 2268x4032
decode FAILED: source: bad seek to 1956033
photo.heic 199693 bytes | brand heic | metadata: heif hevc 800x600
decode FAILED: source: bad seek to 199725
metadata() gives you the format, the codec and the size. The decode fails with "bad seek", which reads like a truncated upload. If you log that and move on, your users see "corrupt file" for a perfectly good photo.
Note: Do not branch on
format === 'heif'. An AVIF file reportsformat: 'heif'too, withcompression: 'av1', and sharp decodes it fine. Branch oncompression === 'hevc'.
How do you decode the HEVC ones?
heic-decode wraps libheif compiled to WebAssembly (via libheif-js), so it needs no system libraries. Its README documents decode.all(), which lists the images in a file before decoding any pixels. That lets you check the size first.
import sharp from 'sharp';
import decode from 'heic-decode';
export async function toSharp(buf) {
const meta = await sharp(buf).metadata(); // reads the container: works for HEVC too
if (meta.format === 'heif' && meta.compression === 'hevc') {
const [first] = await decode.all({ buffer: buf });
if (first.width * first.height > 50_000_000) throw new Error('too many pixels');
const { width, height, data } = await first.decode(); // RGBA, 8 bits per channel
return sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
raw: { width, height, channels: 4 },
});
}
return sharp(buf); // JPEG, PNG, WebP, AVIF: sharp decodes these itself
}
const out = await (await toSharp(buf)).resize({ width: 1080 }).jpeg({ quality: 80, mozjpeg: true }).toBuffer();
HEIC 1956001 bytes -> JPEG 296230 bytes, 1080x1920, median of 5: 650 ms
of which resize + encode alone: 195 ms (median of 5); decoded raster 36578304 bytes, 4 channels
It works. The 2 MB HEIC became a 296,230-byte JPEG at 1080 × 1920. But note the decoded raster: 36,578,304 bytes of RGBA for one photo. Ten concurrent uploads hold about 366 MB of pixels before sharp even starts.
Why is the decode a problem for a server?
The WASM decode is synchronous. While it runs, Node cannot serve anything else. I measured the longest event-loop stall during a decode with monitorEventLoopDelay:
import { monitorEventLoopDelay } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();
const [first] = await decode.all({ buffer: buf });
await first.decode();
h.disable();
console.log('longest stall', (h.max / 1e6).toFixed(0), 'ms');
decode 483 ms, longest event-loop stall 463 ms (medians of 5)
For 463 ms, every other request on that process waits. A timer cannot rescue you either, because the timer callback cannot run until the decode returns.
How do you keep the event loop free?
Move the decode into a worker_threads worker. The main thread stays free, and worker.terminate() can stop a decode that runs too long.
// decode-worker.mjs
import { parentPort, workerData } from 'node:worker_threads';
import decode from 'heic-decode';
const [first] = await decode.all({ buffer: new Uint8Array(workerData) });
const { width, height, data } = await first.decode();
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
parentPort.postMessage({ width, height, data: bytes }, [bytes.buffer]);
// main thread
import { Worker } from 'node:worker_threads';
function decodeInWorker(buf, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
const copy = new Uint8Array(buf).buffer; // a transferable copy
const worker = new Worker(new URL('./decode-worker.mjs', import.meta.url), {
workerData: copy,
transferList: [copy],
});
const timer = setTimeout(() => { worker.terminate(); reject(new Error('HEIC decode timed out')); }, timeoutMs);
worker.once('message', (m) => { clearTimeout(timer); worker.terminate(); resolve(m); });
worker.once('error', (e) => { clearTimeout(timer); reject(e); });
});
}
decoded 2268 x 4032
worker decode 574 ms, longest event-loop stall 12 ms (medians of 5)
The decode takes about 90 ms longer, which covers starting the thread and copying the buffers both ways. The stall drops from 463 ms to 12 ms.
| Step, 2268 × 4032 HEIC | Median of 5 |
|---|---|
| Whole job on the main thread (decode, resize, mozjpeg) | 650 ms |
| heic-decode alone | 483 ms |
| Longest event-loop stall, main thread | 463 ms |
| sharp resize to 1080 px and encode | 195 ms |
| Decode in a worker thread | 574 ms |
| Longest event-loop stall, worker | 12 ms |
Checklist for a HEIC upload endpoint
- Sniff the bytes, not the extension. The
ftypbrand at offset 8 saysheic; sharp'scompressionsayshevcorav1. - Send HEVC to the WASM decoder; send everything else to sharp.
- Check
width × heightfromdecode.all()before you calldecode(). - Decode in a worker with a hard timeout.
- Cap concurrency. This 9.1 MP photo alone decodes to about 37 MB of raw pixels.
If you would rather not run any of this, the Blinkimg image API accepts HEIC as an input and returns a web format. My post What is HEIC? explains the format itself.
How I measured
- Machine: Apple M1 Pro, macOS, 2026-10-17.
- Node v24.22.0, sharp 0.35.4 (prebuilt, libvips 8.18.6, libheif 1.23.2 in the bundle), heic-decode 2.1.0 with libheif-js 1.19.8.
- Files:
heic-test.heic(an iPhone 12 Pro photo, 2268 × 4032, 1,956,001 bytes) andphoto.heic(800 × 600, 199,693 bytes), both from the Blinkimg engine's test fixtures. - Times are medians of 5 runs. Event-loop stall is
monitorEventLoopDelayat 10 ms resolution, itsmaxper run.
What I did not test
Colour: the iPhone file carries a 536-byte Display P3 profile, and the raw pixels from heic-decode arrive without it, so this pipeline treats them as sRGB. I also did not test multi-image HEIC, Live Photos, or a libvips built with libde265.
I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)