DEV Community

NullPointerZen
NullPointerZen

Posted on

HEIC to JPG in the browser: the decode took 377 ms, the JPEG encode 65 ms

Our editors run a reader photo submission page, and a good share of what comes in is iPhone .heic. Once I had a WASM decoder reading those files in Chrome, the next complaint was that "convert to JPG" felt slow. My first instinct was to blame the JPEG encoder and drop the quality setting from 80. Before touching anything I timed it. The encoder was not the problem.

Setup

Everything here ran on one Apple M4 Mac with 16 GB of RAM, in the Chromium 149 build that ships with Playwright 1.61.1, headless. The test files are HEICs I encoded myself with macOS sips from synthetic images (gradients, noise, shapes), in three sizes: 3MP, 12MP and 48MP. They are not straight-from-iPhone photos. For the timing I used the HEIC reader and format converter in ImgIng (imging.ai; I ran the Chinese-language version of the app), with its default JPG quality of 80, taking the median of 3 to 19 runs per size.

My first measurement was wrong

I started by timing "click convert" to "file saved". Both 3MP and 12MP came out around 1.3 seconds, which made no sense for images four times apart in pixel count. It turned out my own script waited about 1.2 s before clicking the save button. Once I timed "click convert" to "the new file size appears on the result card", the numbers looked sane.

Decode vs. encode, per size

Size Decode Convert to JPG 80
3MP 104 ms 30 ms
12MP 377 ms 65 ms (58 ms of it in toBlob)
48MP 1.56 s 203 ms

At 12MP the decode costs about 5.8 times the encode. Initializing the decoder took about 10 ms, so that is not where the time goes. The first visit also downloads the decoder (about 510 KB over the wire, gzip). I left download time out because it swung with my network that evening.

Decode (red) vs. convert to JPG (blue) at three sizes on the same M4. Look at the 48MP pair: the decode bar is more than seven times the length of the encode bar.

The decoder is libheif (1.19.x, with libde265) compiled to WASM. When I benchmarked the same bundle on its own, decode() only parsed the container and returned in 0.7 to 9 ms. The real HEVC work happened later, in display(), which fills the RGBA buffer. These files are also tiled. The 12MP one is 48 HEVC tiles of 512×512, and the 48MP one is 54 tiles of 896×1024.

The part that hurts more than the speed

The decode runs on the main thread. My probes caught a synchronous new WebAssembly.Module and new WebAssembly.Instance, and no Worker. The longest long task during decoding was 112.5 ms at 3MP, 393 ms at 12MP and 1.60 s at 48MP. That is roughly the whole decode. At 48MP the page is frozen for a second and a half on an M4. I have no numbers for phones.

"Decode is expensive" only holds for JPG

I almost wrote that line into our team notes. Then I timed the other outputs on the same 12MP file. PNG took 257 ms. WebP at quality 75 took 425 ms, which is slower than the 377 ms decode. At 48MP the WebP toBlob took 1.67 s against a 1.56 s decode. WebP does buy size: 228,496 bytes against 662,071 for the JPG on this 12MP sample. My samples carry synthetic noise, so I would not bet on that ratio for real photos.

What I changed

I left the JPG quality alone, because lowering it would save a few milliseconds at best. ImgIng decodes once into a canvas and each conversion is just a toBlob on that canvas, so switching formats does not decode again. I set up our submission page the same way. Decode once, then pick the format by purpose: JPG when it needs to be fast, WebP when it needs to be small. The 48MP case now gets a "decoding locally" hint next to the button. And from now on I log download, init, decode and encode as four separate numbers, because one total hid the answer from me for an evening.

Top comments (0)