DEV Community

Lank_M
Lank_M

Posted on

A Web Worker fixed the freeze, not the wait: two checks for in-browser AVIF encoding


You wire up an "Export as AVIF" button. You click it, the spinner you added stops mid-turn, the cancel button ignores you, and about a second later a file shows up. Two fixes get proposed almost immediately. The first is to move the encode into a Worker, because then it will be faster. The second is to ship the multi-threaded build, because that one is always faster. I built a test page and timed both before agreeing to either. Neither held in the form it is usually stated.

The page needs its own encoder for a plain reason. AVIF is an image format built on the AV1 video codec. canvas.toBlob(cb, 'image/avif') is the call that should turn a canvas into an AVIF file, and in Chromium 149, Chrome 155 and Firefox 151 it handed me a PNG with no error. Only WebKit 26.5 gave back an AVIF. So you bring your own encoder, the code that turns raw pixels into a compressed file. I used @jsquash/avif 2.1.1, which is libavif v1.0.1 compiled to WebAssembly (WASM), a binary format the browser runs next to your JavaScript. My input was a lake photo from Wikimedia Commons by Jakub Fryš (CC BY-SA 4.0), scaled to 1738×1150 (about 2 MP), at the library defaults of quality 50 and speed 6. Everything ran on one Apple M4 Mac, and every millisecond below belongs to that machine.

Start with the Worker claim. The main thread is the one thread that runs your JavaScript, handles clicks and paints the page, and a Worker is a second thread you can hand a job to. To measure a freeze, I logged the longest gap between two animation frames while the encode ran. requestAnimationFrame asks the browser to call you right before the next paint, so a long gap means nothing was painted in between.

let prev = performance.now(), worstGap = 0;
const watch = () => {
  const now = performance.now();
  worstGap = Math.max(worstGap, now - prev);
  prev = now;
  requestAnimationFrame(watch);
};
requestAnimationFrame(watch);
Enter fullscreen mode Exit fullscreen mode

With the page idle, the worst gap sat between 17 and 25 ms. In Chromium 149 the encode took 762 ms on the main thread, and the worst gap was also 762 ms. In a Worker the same encode took 766 ms and the worst gap was 22 ms. Chrome 155 gave 776 and 775 ms, WebKit 26.5 gave 816 and 830 ms. Each cell is a single run, so I read nothing into which of 762 and 766 is larger.

Longest frame gap while encoding, main thread versus Worker, in three browsers

Red bars are the freeze with the encode on the main thread, green bars are the same job in a Worker. The grey encode times on the left barely differ.

A Worker is a second checkout lane opened for one overloaded cart. The cart takes as long to scan, but everyone behind it keeps moving. So when a proposal says "Worker, therefore faster", I ask which complaint it answers. A frozen page, yes. A long wait, no, and the plan needs a second idea.

That second idea is usually the multi-threaded build, which spreads one encode across several threads. At speed 6 it delivered: the 2 MP photo went from 696 / 696 / 673 ms to 327 / 324 / 323 ms in Chromium 149, and Chrome 155 and WebKit 26.5 were also 2 to 3 times faster. Then I changed speed, libavif's effort setting, where higher means quicker. At speed 8 the single-threaded build took 163 ms and the multi-threaded one 174 ms. At speed 10 it was 103 against 149, so "always" broke on the first parameter I touched. I did not look into why. I also never measured image quality at 8 and 10, so this is not advice to raise the setting.

The multi-threaded build also has a deployment condition. The page must be cross-origin isolated, a state it enters when the server sends two response headers:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Enter fullscreen mode Exit fullscreen mode

My test server sent both. What the build does without them I did not test, and I would want that gap closed before approving it.

To see how a shipped tool handles the loading side, I used ImgIng (https://imging.ai/) in Chromium 149 only, one run per image size. The request log comes from its Chinese-language site, and I watched from outside without reading its code. It fetched avif_enc_mt.wasm, the multi-threaded build, at 1,135,337 bytes over gzip. The fetch began the moment I selected AVIF in the format grid, before I pressed Convert. Its wait times run from the click to the finished state, a different stopwatch from my encode() numbers, so I am not comparing the two.

ImgIng's convert panel right after selecting AVIF, with the On-demand WASM badge and a Load encoder button

This is the English interface right after AVIF is selected. The tile carries an "On-demand WASM" badge, and the bottom note says the browser cannot encode the format natively. I never pressed the "Load encoder" button beside it.

In a design review I now write these as two line items. "Does not freeze" is answered by where the encode runs. "Is faster" is answered by which build at which speed, and gets re-measured whenever either changes. Still open: the missing-headers case, why threads stop paying off at speed 8, and any hardware other than this one M4. No phones, no Windows.

Top comments (0)