I've been building a set of file tools that run entirely client-side — no upload, no server. The image side turned out to need no library and no WASM binary at all. The browser already ships JPEG, PNG and WebP codecs, and shipping your own would cost megabytes to do it worse.
Here's the whole thing:
const bitmap = await createImageBitmap(file)
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height)
const ctx = canvas.getContext('2d')
ctx.drawImage(bitmap, 0, 0)
bitmap.close()
const blob = await canvas.convertToBlob({
type: 'image/webp',
quality: 0.8,
})
Both createImageBitmap and OffscreenCanvas are available inside a Worker, so decode and encode never touch the main thread. That matters more than it sounds — encoding a 12 megapixel photo on the main thread janks the page for a noticeable beat, and users drag in twenty of them at a time.
So far so good. Then I tried to add AVIF.
The trap
convertToBlob takes a type. Pass it something the browser can't encode and you might reasonably expect a rejected promise, or at worst a null.
It does neither. It silently encodes PNG and returns that, with blob.type set to image/png, and resolves successfully.
If you trust the type you asked for — and why wouldn't you — you now write a file called photo.avif that contains a PNG. Nothing throws. Nothing logs. The user downloads it, and it fails days later in some other program, with an error that points nowhere near your encoder.
That's worse than a crash. A crash tells you where you are.
Probe it yourself
Don't take my word for which formats work. Paste this into a console:
const probe = async (type) => {
const c = new OffscreenCanvas(8, 8)
c.getContext('2d').fillRect(0, 0, 8, 8)
const b = await c.convertToBlob({ type })
return `${type} -> ${b.type}${b.type === type ? '' : ' ⚠️ SILENT FALLBACK'}`
}
for (const t of ['image/jpeg', 'image/png', 'image/webp', 'image/avif']) {
console.log(await probe(t))
}
In Chrome when I ran it:
image/jpeg -> image/jpeg
image/png -> image/png
image/webp -> image/webp
image/avif -> image/png ⚠️ SILENT FALLBACK
Browsers decode AVIF widely at this point. Encoding through a canvas is a separate question, and the answer is no. Worth re-running this in Safari and Firefox rather than assuming my Chrome result generalises — the whole point is that the API won't tell you.
The fix is one line
Check what you actually got:
if (blob.type !== requestedType) {
throw new Error(`This browser cannot write ${label} images.`)
}
That's it. The value isn't the check, it's having the check at all — because the failure mode this catches is invisible at every other layer.
Two notes on the error message, which took me a second pass to get right:
Don't claim the browser can't decode the input. "This browser cannot decode PNG" is false and sends someone hunting for a converter they don't need. The failure is on the encode side.
Don't silently substitute. I briefly considered falling back to WebP and renaming the file. That's the same bug wearing a helpful hat — the user asked for one thing and got another without being told.
What I did with the result
I shipped no AVIF tool. A format the browser can't encode needs a WASM encoder, which is a real dependency with a real byte cost, and "we support AVIF" isn't worth several hundred kilobytes on every image page when the honest answer is that WebP already gets you most of the way.
The JPEG/PNG/WebP triangle covers what people actually convert, costs zero bytes of library, and runs in a Worker. That's a good trade.
If you want to see it running, the image tools on Orpheus all use exactly this path — nothing is uploaded, so you can open the network tab and watch it stay empty while a file converts. The image converter is the plainest example.
The general lesson
Web APIs that "helpfully" degrade instead of failing are the expensive kind of bug. convertToBlob is one. So is anything that returns a default instead of throwing.
The defence is cheap and always the same: assert on what you got back, not on what you asked for.
Top comments (0)