DEV Community

Lank_M
Lank_M

Posted on

Stop UA-sniffing canvas encoders: probe toBlob once at page load


Here is a line I still find in image upload code: if (!/Safari/.test(navigator.userAgent)) type = 'image/webp'. It is wrong in both directions. Chromium's user agent contains the word "Safari" too, so Chrome users lose WebP for no reason. And the UA string tells you nothing about what the canvas can actually encode.

I work on the in-browser encoding side of imging, and we never picked output formats by UA. This week I re-checked how that holds up. The setup I used: the live build of imging from 09-03, run in the three engines bundled with Playwright 1.61.1 (Chromium 149, WebKit 26.5, Firefox 151, all headless on an M4 Mac), with a synthetic 2000×1500 test image.

Decoding a format is not encoding it

The WebKit 26.5 build reports Version/26.5 Safari/605.1.15. It displays WebP images fine. Its canvas.toBlob('image/webp') cannot produce WebP. Meanwhile the same build encodes AVIF, BMP, GIF and HEIC, which Chromium 149 cannot. Chromium encodes PNG, JPEG and WebP only. Firefox 151 adds BMP. There is no version line you can draw.

Which formats toBlob can encode in each engine

Look at where the red cells are: every column has some. The red cells also share one behavior that makes UA logic dangerous. An unsupported type silently falls back to PNG. No exception, no null, no console warning. Asking this WebKit build for WebP from our photo sample returned a 7,993,002-byte PNG, 19.8 times the size of Chromium's default WebP. If your code names that file photo.webp, the server receives a large file with a lying extension. Even the typo image/jpg (missing the e) falls back to PNG in all three.

Probe once at load

The fallback is honest in one place: the returned blob.type says image/png. So instead of asking which browser this is, ask the encoder:

// dev.to: same idea, OffscreenCanvas flavour
const FORMATS = ['image/webp', 'image/avif', 'image/jpeg'];
async function canEncode(mime) {
  const oc = new OffscreenCanvas(4, 4);
  oc.getContext('2d').fillRect(0, 0, 4, 4);
  const blob = await oc.convertToBlob({ type: mime });
  return blob.type === mime;            // a silent PNG fallback fails this check
}
Enter fullscreen mode Exit fullscreen mode

I ran this in all three engines. Chromium and Firefox answered webp true, avif false, jpeg true. WebKit answered webp false, avif true, jpeg true. That matches the full table above. A 4×4 canvas is enough because the format decision does not depend on size. The one size to avoid is 0×0, which was the only case where toBlob returned null in my tests.

What the probe decides in imging

On load, imging draws nothing fancy: a 4×4 canvas, one toBlob call each for png, jpeg, webp and avif. No WASM is loaded at that point. The answers pick a path per output format.

imging picks an encoder from a live capability test

The two green cells sit in different columns, and that is the point. For WebP, Chromium and Firefox use canvas.toBlob('image/webp', 0.8), and the output is byte-identical to a direct canvas export (404,244 and 404,558 bytes). WebKit fails the probe, so the page fetches a self-hosted libwebp WASM encoder on demand, which produced 500,178 bytes. AVIF goes the other way: Chromium gets libavif in WASM, WebKit uses its native encoder. The same code takes opposite routes in two engines with zero per-browser branches. We did not write these encoders. The native ones ship with the browser, the WASM ones are open-source libwebp and libavif. Our part is the probe, the routing and the lazy loading.

Across 37 test runs in the three engines, there were zero non-GET requests. The only extra traffic was the GET for an encoder file the first time it was needed. That covers the common formats in the main app, not every feature on the site.

What the probe does not tell you

It answers "can this engine encode X". It does not answer "what will X look like". At the same JPEG quality 0.8, the WebKit build produced a file 2.53 times larger than Chromium's, and both passed the probe. Quality mapping needs separate calibration per engine, and I have not found a cheaper way than measuring bytes. I also did not run the AVIF path in Firefox, and none of this was tested on real Safari or iOS, only the Playwright builds.

If you have UA-based export code today, add one log line first: record the requested type next to the returned blob.type. Every row where they differ is a user who got a file that is not what its name says.

Top comments (0)