A user told me the .webp files my tool produced wouldn't open on their desktop. I opened one in a hex editor. First four bytes: .
89 50 4E 47
It was a PNG. With a .webp extension.
The encoder wasn't broken. I had simply never checked whether the browser actually did what I asked.
The spec says it's allowed to do this
Here's the code. Nothing looks wrong with it:
canvas.toBlob(blob => {
download(blob, 'output.webp');
}, 'image/webp');
The callback fires. The blob isn't null. Its size looks reasonable. Everything succeeds — except it isn't WebP.
This is not a bug. The HTML spec explicitly requires it: if the user agent doesn't support the requested type, it must create the file using the PNG format instead. No exception, no warning, no second argument telling you what happened.
There's exactly one place that information exists — blob.type:
canvas.toBlob(blob => {
console.log(blob.type); // iOS below 16.4: "image/png"
}, 'image/webp');
toDataURL does the same thing, but at least there the fallback is visible to the naked eye, since the data URL literally starts with data:image/png;base64,.
There is no capability query for this
My first instinct was to special-case iOS. That falls apart quickly.
Every browser on iOS is WebKit underneath, so "is this Safari" isn't a meaningful question. Embedded webviews inside apps track the system version in ways that don't always match the standalone browser. And a user can flip on "Request Desktop Website" and hand you a macOS user agent from an iPhone.
More fundamentally: the user agent string answers "who are you", and I need to know "can you encode WebP right now". Between those two questions sit the engine version, OS version, host app, and build flags. Any mismatch in that chain and your lookup table lies to you.
So I went looking for an official capability API. Media has them:
MediaRecorder.isTypeSupported('video/webm;codecs=vp9'); // → boolean
await navigator.mediaCapabilities.encodingInfo({ ... }); // → supported, smooth, powerEfficient
Canvas image encoding has nothing equivalent. toBlob and toDataURL expose no capability query at all. The spec defines the fallback behaviour but never gives you a way to ask about it up front.
That information isn't hidden — it genuinely doesn't exist at the API level. Which leaves one option: measure it.
Just encode something and check what comes back
const encodeCache = new Map();
async function canEncode(mime) {
if (encodeCache.has(mime)) return encodeCache.get(mime);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 2;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgba(0, 128, 255, 0.5)'; // semi-transparent, also probes alpha
ctx.fillRect(0, 0, 1, 1);
const blob = await new Promise(resolve => canvas.toBlob(resolve, mime));
const ok = !!blob && blob.type === mime; // ← the whole point is this line
encodeCache.set(mime, ok);
return ok;
}
Everything rests on blob.type === mime. Without it you've only confirmed the browser was willing to hand you a blob, not that it handed you the one you asked for.
I ran this on desktop Chromium across six formats:
| Requested | Actually returned | Size | Verdict |
|---|---|---|---|
| image/webp | image/webp | 564 B | encodes |
| image/jpeg | image/jpeg | 796 B | encodes |
| image/avif | **image/png** | 95 B | falls back |
| image/heic | **image/png** | 95 B | falls back |
| image/tiff | **image/png** | 95 B | falls back |
| image/gif | **image/png** | 95 B | falls back |
Two things worth noticing.
Desktop Chrome cannot encode AVIF. It decodes AVIF perfectly well — drop one in an and it renders. But canvas won't produce one. I had assumed this was a mobile-only problem. It isn't.
The last four rows are byte-for-byte identical at 95 bytes, because they're literally the same 2×2 PNG. If you only check whether the blob is null, all six formats "succeed."
Decoding needs probing too, and it's the harder half
Encoding and decoding are separate code paths, shipped independently. Being able to read a format tells you nothing about writing it — Safari 16 decodes AVIF but can't encode it; nothing mainstream encodes HEIC at all.
Probing decode support has an extra prerequisite: you need a sample file in that format to try. You can't ask the user to upload an AVIF just to find out whether AVIF is readable.
So inline them as base64. They only need to be parseable, not meaningful — a 1×1 AVIF runs a couple hundred bytes, and a handful of formats together costs a kilobyte or two.
const PROBES = {
'image/avif': 'data:image/avif;base64,AAAAIGZ0eXBhdmlm…',
'image/webp': 'data:image/webp;base64,UklGRh4AAABXRUJQ…',
};
async function canDecode(mime) {
const src = PROBES[mime];
if (!src) return false;
try {
const blob = await (await fetch(src)).blob();
const bitmap = await createImageBitmap(blob);
bitmap.close?.();
return true;
} catch {
return false;
}
}
One trap: don't use new Image() with onload/onerror for this. Behaviour varies — some browsers fire onerror for undecodable images, others fire onload with naturalWidth === 0. createImageBitmap has cleaner semantics: it either resolves or throws.
Cost and timing
Probing isn't free, especially when a format needs a WASM codec loaded first.
Cache the result. One probe per MIME type per session.
Keep the test canvas small, but not 1×1. I started at 1×1 and moved to 2×2 — some encoders take special paths for degenerate sizes, so a 1×1 result isn't necessarily representative.
Probe during dead time. Not on first paint (it delays render), not on the convert click (it adds latency the user feels). I run it after the file is selected, while the user is still choosing options.
When the probe fails, give the user a road
Knowing something is unsupported is only half the job.
Swap to an equivalent format — and say so out loud. Silently substituting formats is the same sin as the silent PNG fallback that started all this.
Load a WASM codec on demand. No native encoder doesn't mean the device can't do it. libwebp, libavif and libheif all compile to WASM. Worth stating plainly: downloading a codec sends a program to the device — the image never travels the other way.
Fall back to a server. Sometimes unavoidable. But then say which path is being taken, in the UI, before the user clicks. If your selling point is that files stay local, users are entitled to know exactly when that stops being true.
The one I haven't solved
HEIC encoding. HEVC licensing is a genuine minefield, and a WASM build of a usable HEVC encoder is far too large to ship for an output format almost nobody asks for. It goes to a server, labelled as such.
If anyone has shipped a workable client-side HEVC encoder, I'd love to hear about it.
The short version: a browser API not throwing doesn't mean it did what you think it did. If you're encoding images client-side, add the blob.type === mime check today — before a user shows up with a file that won't open.
I write about this stuff while building imging.cn, a browser-based image engine where common formats are converted, compressed and matted locally without upload.
This article was originally published by DEV Community and written by Lank_M.
Read original article on DEV Community