If you have ever written canvas.toBlob(callback, 'image/avif'), there is a good chance your users have been downloading PNG files with an .avif name.
That is what happened to me. I build Filewhisk, a set of image tools that run entirely in the browser, and two of its pages were supposed to convert JPG and PNG to AVIF. The encoder was the browser's own canvas. I believed Chrome had been able to encode AVIF for years. It can't, and neither can any other major browser.
This post covers how I found out, how to detect it properly, and the four traps I hit while shipping AVIF export with a WebAssembly encoder instead.
toBlob does not fail. It falls back.
The canvas spec says that if the browser does not support the requested type, it must return a PNG. No exception, no rejected promise, just a different blob.type.
So I measured it on Chrome 153:
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 16;
canvas.toBlob(b => console.log(b.type), 'image/avif');
// image/png
const oc = new OffscreenCanvas(16, 16);
oc.getContext('2d');
console.log((await oc.convertToBlob({ type: 'image/avif' })).type);
// image/png
Both paths return PNG. There is a long-standing Chromium issue for exactly this (40848792, "Canvas.toBlob cannot encode AVIF"), and Firefox and Safari do not encode AVIF from a canvas either. Where did my belief come from? Chrome 85 added AVIF decoding. Displaying a format and producing it are different features, and I had merged them in my head.
My tool did one thing right: it checked blob.type before offering a download, so users got a "not supported" message instead of a mislabelled PNG. But that also meant two pages could not do the one thing their titles promised.
Detect it, don't assume it
If you use canvas encoding for anything other than PNG, test the actual output type once:
function canEncode(mime) {
return new Promise(resolve => {
const c = document.createElement('canvas');
c.width = c.height = 1;
c.toBlob(blob => resolve(!!blob && blob.type === mime), mime, 0.8);
});
}
WebP passes this test in Chromium browsers. AVIF passes nowhere today.
The fix: libavif compiled to WebAssembly
Sending the image to a server was not an option, because "your files never leave your device" is the whole point of the site. So the encoder had to run in the browser.
jSquash packages the codecs from Google's Squoosh app for use in the browser, including an AVIF encoder built from libavif (@jsquash/avif, Apache-2.0). It works well. Getting it into a plain static site without a bundler took more care than I expected.
Trap 1: the multi-threaded build needs cross-origin isolation
The package ships a single-threaded and a multi-threaded encoder. The multi-threaded one is faster, but it relies on SharedArrayBuffer, which requires the page to be cross-origin isolated (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers). Those headers block cross-origin resources that don't opt in, and third-party scripts like ad and analytics tags generally don't. For an ad-supported site that is a non-starter, so I used the single-threaded build only.
Trap 2: the convenience wrapper assumes a bundler
The package's encode.js imports a bare specifier (import { threads } from 'wasm-feature-detect') and, when threads are available, switches to the multi-threaded build. Without a bundler or an import map, a bare specifier simply fails to load. I skipped the wrapper and imported the compiled single-threaded codec directly: codec/enc/avif_enc.js, which loads its .wasm file relative to import.meta.url.
Trap 3: encoding blocks the page, so use a module worker
A 1600×719 photo takes about 1.3 to 1.7 seconds to encode on my machine. On the main thread that is a frozen page. The encoder module works fine inside a module worker:
// avif-worker.js
import createModule from './avif_enc.js';
const DEFAULTS = {
quality: 50, qualityAlpha: -1, denoiseLevel: 0, tileColsLog2: 0,
tileRowsLog2: 0, speed: 6, subsample: 1, chromaDeltaQ: false,
sharpness: 0, tune: 0, enableSharpYUV: false, bitDepth: 8, lossless: false
};
let modulePromise = null;
self.onmessage = async ({ data: msg }) => {
try {
modulePromise ??= createModule({ noInitialRun: true });
const mod = await modulePromise;
const out = mod.encode(new Uint8Array(msg.pixels), msg.width, msg.height,
{ ...DEFAULTS, ...msg.options });
if (!out) throw new Error('AVIF encoding failed');
const buffer = out.slice().buffer;
self.postMessage({ id: msg.id, buffer }, [buffer]);
} catch (err) {
modulePromise = null;
self.postMessage({ id: msg.id, error: String(err?.message ?? err) });
}
};
On the page, draw the image to a canvas, take the RGBA pixels and transfer the buffer instead of copying it:
const worker = new Worker('/vendor/avif-2.1.1/avif-worker.js', { type: 'module' });
function encodeAvif(canvas, quality) {
const { data } = canvas.getContext('2d')
.getImageData(0, 0, canvas.width, canvas.height);
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const onMessage = ({ data: msg }) => {
if (msg.id !== id) return;
worker.removeEventListener('message', onMessage);
msg.error ? reject(new Error(msg.error))
: resolve(new Blob([msg.buffer], { type: 'image/avif' }));
};
worker.addEventListener('message', onMessage);
worker.postMessage(
{ id, pixels: data.buffer, width: canvas.width, height: canvas.height,
options: { quality } },
[data.buffer]
);
});
}
Two more details. Listen for the worker's error event and reject pending jobs, otherwise a failed download leaves promises hanging forever. And keep using the native path first: if canEncode('image/avif') ever returns true, skip the worker entirely.
The encoder is about 3.5 MB of WebAssembly (roughly 1 MB over the wire with Brotli). It is only fetched the first time someone actually converts to AVIF, so the rest of the page pays nothing.
Trap 4: files loaded by the worker can't be cache-busted
My build appends a content hash to script URLs (toolkit.js?v=1a2b3c4d) so that a deploy never serves stale JavaScript. That works for the worker URL itself, which I stamp at build time. But the worker then loads avif_enc.js and avif_enc.wasm by relative path, and those requests cannot carry my hash. The fix is boring and reliable: put the encoder in a versioned folder (vendor/avif-2.1.1/) and treat it as immutable. Upgrading means a new folder, never overwriting the old one.
Mapping a "quality" slider to AVIF
The tool's quality menu is on a JPEG-style scale (95, 85, 75, 60), but libavif's quality value means something different: the same number compresses much harder. I measured file size and PSNR against the source on a real 1600×719 landscape photo and chose a mapping where AVIF comes out smaller than JPEG without falling behind it on PSNR:
| Setting | JPEG | AVIF (libavif quality) |
|---|---|---|
| 95 | 443 KB, 37.5 dB | 209 KB, 37.8 dB (80) |
| 85 | 230 KB, 34.5 dB | 133 KB, 36.2 dB (64) |
| 75 | 165 KB, 33.4 dB | 84 KB, 34.5 dB (52) |
| 60 | 121 KB, 32.4 dB | 39 KB, 32.1 dB (38) |
One warning from the process: my first calibration used a synthetic noisy texture, and AVIF appeared to fall off a cliff at the medium setting (102 KB down to 14 KB). It was simply discarding the artificial noise. Calibrate codecs on real photos.
Testing notes
A few things that cost me time and might save you some:
-
Check the output bytes, not the MIME type you assigned. A real AVIF file has
ftypavifat bytes 4–11. Decode it back withcreateImageBitmapand compare a few pixels, including alpha if the source had transparency. -
Module workers must be same-origin. I originally tested the live site by loading its HTML from a local server with a
<base href>pointing at production. Scripts loaded fine, but the worker resolved to another origin and the browser refused it, which looked exactly like a broken deploy. Test worker-based pages on their real origin. -
Headless screenshots can lie about WebAssembly. Chrome's
--screenshotwith--virtual-time-budgetdid not wait for WASM compilation, so every screenshot captured the page before the result existed. Driving the page through the DevTools protocol and waiting for a real "done" signal fixed it.
Takeaways
-
canvas.toBlobandconvertToBlobsilently return PNG for unsupported types. Always checkblob.type. - No major browser encodes AVIF from a canvas today. If you need AVIF output in the browser, bring an encoder.
- For sites with third-party scripts, the single-threaded WASM build in a module worker is the practical choice.
- Load the encoder lazily, version its folder, and calibrate quality on real images.
If you want to see the result, the JPG to AVIF converter linked at the top of this post runs exactly this setup. The image never leaves your browser.
Top comments (3)
The toBlob fallback detail is the one I'd have shipped wrong for months. The spec's 'unsupported type must return PNG' reads like a compatibility grace on paper, but it means the failure mode is a mislabelled download instead of an error — users keep the broken file and nobody sees a console message.
The versioned-folder answer for the worker's relative-path wasm is the part I'll steal: content-hashing the worker URL yourself while the inner avif_enc.js loads by bare path gives you exactly the stale-cache gap you described, and an immutable vendor dir is a cheaper fix than trying to thread a hash through import.meta.url. Did you measure how much the single-threaded encoder actually costs you at 1600x719 versus the SharedArrayBuffer build, or did the ad-tag constraint make that a non-question?
Thanks, and you put the toBlob point better than I did: the file downloads fine, the name looks right, and the only place the failure shows up is whatever tool the user opens it in later.
To your question: no, I didn't benchmark the multi-threaded build. Cross-origin isolation (COOP/COEP) would have broken the ad and analytics scripts on the site, so it was never a real option here, and I'd rather not guess at a number I haven't measured. What I can say is that the single-threaded encoder takes about 1.3–1.7 s for a 1600×719 photo on my machine, and because it runs in a module worker the page stays responsive, which mattered more to me than raw speed for a one-off conversion.
If I set up an isolated test page to compare the two builds, I'll add the numbers to the post.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.