Canvas AVIF Encoding: Why Every Browser Returns a PNG
Every modern browser can display an AVIF image. None of them can produce one from a canvas.
Ask canvas.toBlob() for image/avif in Chrome, Edge, Firefox, or Safari today and you will get a valid blob back, instantly, with no exception and no console warning. It is a PNG. We know because we measured it — and because the same silent-fallback rule, applied to Safari's missing WebP encoder, is exactly how our own regression suite once caught a "compressed" download that was five times bigger than its source.
This post is the engineering story behind AVIF export in our converters: what the toBlob contract actually promises, how to detect the fallback, why Safari's WebP gap makes the same trap expensive, and what it takes to ship a WASM encoder that behaves the same in every browser. For the raw cross-browser encoder measurements, see the companion benchmark post — this one is about what we built after reading them.
Quick Answer
- Nobody encodes canvas AVIF natively. We probed release builds of Chrome 155, Edge 155, Firefox 158, and Safari (October 2026): every
image/avifrequest throughtoBlob/toDataURLcame back as PNG. - The fallback is silent by specification. The HTML spec tells browsers to fall back to
image/pngwhen the requested type is unsupported — not to throw. Detection is on you: checkblob.type. - The same trap exists for WebP in Safari, where it is much more expensive: a 12 MP photo requested as WebP at quality 0.8 came back as a 20.55 MB PNG — about five times the source file.
- The fix is a WASM encoder in a worker (we use libaom via
@jsquash/avif), with quality levels anchored to a scale users already understand, and the single-threaded preset shipped deliberately.
The Contract Trap: type Is a Hint, Not a Command
canvas.toBlob(callback, type, quality) reads like a command: encode this canvas as type. The spec actually treats type as a preference. If the browser has no encoder for the requested MIME type, it does not reject, does not resolve with null, and does not log anything — it encodes PNG and returns that, with the blob's type set to image/png.
That is a reasonable design for a platform API — it guarantees you always get something — but it inverts the failure mode for application code. A missing encoder is not an error you can catch; it is a successful call with the wrong output. Every downstream assumption ("this is a WebP file, it's small") quietly breaks.
The cleanest proof of the fallback is byte-level: encode a 300×200 gradient canvas twice, once requesting image/avif and once requesting image/png. On every engine we probed, the two blobs are byte-for-byte identical — 16,552 bytes on Chromium-family browsers, 7,508 bytes on Firefox (release Chrome 155 / Edge 155 / Firefox 158, probed October 22, 2026). Identical bytes mean no AVIF encoder ran, anywhere, in any mode.
Detection is one comparison:
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, 'image/avif', 0.8)
);
if (blob.type !== 'image/avif') {
// Silent fallback: blob is a PNG no matter what we asked for.
}
Check blob.type after every canvas encode whose format matters. It is the only signal the platform gives you.
It's Not Just AVIF: Safari's Missing WebP Encoder Costs Real Money
The same fallback rule applies to WebP — and there the gap has a name: Safari has no WebP canvas encoder. Request image/webp from a canvas in Safari and you get a PNG (long-open WebKit feature request 183257; libraries like browser-image-compression have hit the same wall).
The damage scales with image size, because PNG is lossless:
| Request (12 MP benchmark photo, q 0.8) | Safari returns | Size |
|---|---|---|
image/webp | PNG | 25.1 MB (source: 4.4 MB HEIC) |
image/jpeg | JPEG | 6.06 MB |
| same JPEG request on Chrome | JPEG | 3.53 MB |
Two things to notice. First, the WebP request produced a file 5.7× the size of its HEIC original — a "compress" action that inflates. Second, Safari's JPEG encoder is not broken: sweeping quality 10→100 gives a clean monotonic 1.19→17.7 MB curve, so quality works. It is just materially less efficient than Chromium's at the same setting — about 1.7× the bytes at q 0.8 on the same 12 MP photo (Safari 18.6 vs Chrome, measured September 2026).
This is the classic shape of a cross-browser encoding bug: every test passes on Chromium, and the failure only appears on the engine you didn't test — where your tool tells users "done!" and hands them a file bigger than the one they started with.
What We Ship Instead: WASM Encoders Behind the Same API
Our fix routes by engine:
- Chromium and Firefox keep the native
toBlobpath — their WebP and JPEG encoders are good and fast. - WebKit family (Safari) routes through WASM encoders running in a Web Worker: WebP via libwebp (
@jsquash/webp), JPEG via MozJPEG (@wasm-codecs/mozjpeg).
The WASM route restores the contract: the requested type is the returned type, and Safari users get Chrome-class file sizes. Integration had real gotchas, each of which cost debugging time:
- MozJPEG's embind options struct must be fully populated. Leave out one field and the call throws — the error names the struct, not the field.
getImage()returns a view into the WASM heap, and heap views are not transferable. Passing one topostMessagefails;slice()a copy out first.- MozJPEG has no alpha channel. Flatten transparency yourself — we composite onto white. If you naively drop the alpha byte from canvas RGBA, transparent pixels come out black.
After the switch, the WebKit tests that used to emit files larger than their sources went green across WebKit and Mobile Safari runs, and the Chromium/Firefox suites showed no regression — the native path is untouched.
AVIF: The One You Always Encode Yourself
For AVIF there is no native path to preserve anywhere, so every browser takes the WASM route: libaom, via @jsquash/avif, inside a worker. That works identically across engines — which is exactly what a cross-browser save path needs — but it moves the cost question front and center, because libaom is slow.
The Speed Wall — and Why speed Is Not the Knob
libaom exposes a speed preset that looks like the obvious lever. We measured it across the full quality range on a 2.2 MP image, comparing each setting's DSSIM against JPEG references encoded on the same rig:
| AVIF quality | speed 6 DSSIM | speed 8 DSSIM | speed 9 DSSIM | JPEG reference DSSIM |
|---|---|---|---|---|
| 40 | 0.0352 | 0.0534 | 0.0745 | q61 = 0.0014 |
| 50 | 0.0154 | 0.0280 | 0.0383 | q76 = 0.0035 |
| 60 | 0.0064 | 0.0167 | 0.0191 | q85 = 0.0017 |
| 70 | 0.0037 | 0.0115 | 0.0115 | q89 = 0.0012 |
| 80 | 0.0027 | 0.0057 | 0.0063 | q91 = 0.0009 |
Speed 8 and 9 are disqualifying on quality alone: their distortion is higher than every JPEG reference at every quality level, so the quality mapping below stops meaning anything. And they don't even buy size — at quality 50, speed 8 produced 323 KB vs speed 6's 168 KB (+92%). Faster encoding, bigger files, worse fidelity.
So speed 6 it is, and speed 6 is slow. Wall-clock on our rig at speed 6:
| Image | Chrome | Firefox |
|---|---|---|
| 2.2 MP | ~4 s | ~26 s |
| 12 MP | ~2 min | ~3.7 min |
Those numbers are why the encoder must live in a worker with honest progress feedback — a multi-minute main-thread encode is a frozen page. (Timings are single-run and machine-specific; treat them as order-of-magnitude, not benchmarks. The quality and fallback results above are deterministic for a given encoder version and are the numbers worth relying on.)
The Quality Map: Speaking the User's Language
libaom's quality value is not anyone's intuition. Our converters expose AVIF quality as its JPEG equivalent, anchored by DSSIM on the same rig as the table above: AVIF 40 ≈ JPEG 61, AVIF 50 ≈ 76, AVIF 60 ≈ 85, AVIF 70 ≈ 89, AVIF 80 ≈ 91. The mapping is exactly what the speed experiment protected — at speed 8/9 the anchors dissolve, which is the real reason those presets are unusable, beyond raw quality loss.
If you build encode UI, anchor your quality scale to something users already have intuition for, and publish how you anchored it. "Quality 40" without a reference system is a number only the encoder understands.
Why We Ship Single-Threaded (For Now)
libaom has a pthread build (@jsquash/avif ships one: ~54 KB of JS, a 3.4 MB wasm module, and worker glue) that parallelizes encoding across image tiles. With tiles enabled, our back-of-envelope estimate puts the 12 MP Firefox encode at 30–60 s instead of ~3.7 min — a real win.
We haven't shipped it, deliberately. Multithreaded wasm needs SharedArrayBuffer, which requires the page to be cross-origin isolated, which means COOP/COEP response headers on every page of the site. That is a site-wide security-header change — every third-party script has to be re-validated under COEP credentialless (Chrome 96+/Firefox 100+/Safari 17+; older engines simply don't isolate and would keep the single-threaded path). Worse for us: in our local test setup, astro preview does not apply the _headers file, so end-to-end tests can never exercise the multithreaded path — we would be shipping a code path CI cannot see.
The fallback story is the one clean part: @jsquash feature-detects threads at runtime and imports the single- or multi-threaded variant automatically, so the code design already tolerates both worlds. The day AVIF export volume justifies a site-wide header change (and its deploy-review), the multithreaded variant is a measured, documented option — not a research project. Until then, single-threaded is a decision, not an omission.
If You're Building the Same Thing: A Checklist
- Never trust the requested type. Check
blob.typeafter every canvas encode; that's the whole detection. - Route engines deliberately. Native
toBlobwhere it's good (Chromium/Firefox), WASM where it isn't (Safari WebP/JPEG), WASM everywhere for AVIF. A tiny encode-probe at startup is more robust than UA sniffing if you can afford it. - Workers, always. WASM encoders are CPU-heavy; a multi-second (or multi-minute) encode must not touch the main thread. Watch the transfer traps:
slice()heap views beforepostMessage. - Anchor quality to a scale users know (we use JPEG equivalents via DSSIM) and publish the anchor. Pin your encoder preset — we verified that libaom
speed8/9 silently changes what quality levels mean. - Size-check your output. The cheapest regression test we ever wrote: encoded file must not be larger than the source. It's the exact failure the silent fallback produces, and it catches encoder regressions for free.
- Re-run the probe per browser release. Encoder support is a moving target — Safari could ship a WebP canvas encoder any month, and engines could add AVIF encoding. Our probe is a 5-minute MIME matrix per release; it has already caught one full version cycle of "still no change".
Where this ships in our tools: the image converters — for example PNG to AVIF runs this exact pipeline (worker-offloaded libaom at speed 6, JPEG-anchored quality) entirely in your browser. Nothing is uploaded; the encoder is the only thing that travels.
Reproduce It Yourself
The MIME probe above is a few lines: draw a 300×200 gradient, toBlob once per requested type in {jpeg, webp, avif, png}, and log each returned blob.type and size — byte-identical output for two different requested types is your proof of fallback. The full benchmark harness behind the companion post is open source (MIT): run it at lab.pixkeep.app/encode-matrix, read the source at github.com/pixkeep/lab, or download the raw JSON exports that back every number in both posts.
Frequently Asked Questions
Does canvas.toBlob support AVIF?
No. As of October 2026, none of the four major browser engines — Chrome, Edge, Firefox, or Safari — can encode AVIF through canvas.toBlob or toDataURL. When you request 'image/avif', every one of them silently encodes a PNG instead and returns it with type 'image/png'. Nothing throws; the failure is invisible unless you check the returned type.
How do I detect the silent PNG fallback?
Compare the returned blob's type with the type you requested. If you asked for 'image/avif' or 'image/webp' and got back 'image/png', the browser had no encoder for your requested format and fell back. Checking blob.type after every encode is the only reliable detection — the call itself never fails.
Which browsers can encode WebP from a canvas?
Chromium-based browsers and Firefox have native WebP canvas encoders. Safari does not: a request for 'image/webp' comes back as a PNG. On a 12-megapixel photo at quality 0.8, that fallback produced a 20.55 MB file — five times the size of the source — because PNG is lossless. Safari's JPEG encoder works and responds to quality, but produces roughly 1.7 times the bytes of Chrome's at the same setting.
How do you encode AVIF in the browser, then?
With a WebAssembly encoder running in a Web Worker. We use libaom through the @jsquash/avif package. It works in every modern browser, but it is CPU-heavy: a 12-megapixel image takes about two minutes in Chrome and close to four in Firefox at libaom's speed-6 preset. That is why the encode must live in a worker with real progress feedback, never on the main thread.
Why not use libaom's speed setting to make AVIF encoding faster?
We measured it. Raising libaom's speed parameter from 6 to 8 or 9 broke the quality-to-size relationship at every quality level we tested: quality metrics got worse than any JPEG reference we map against, and files got up to 92 percent larger at the same quality setting. Speed 6 is the slowest preset but the only one where quality level 40 still means the same thing it meant yesterday, so it is the only one we ship.
Why not multithread the AVIF encoder with Web Workers?
It is possible — libaom ships a pthread build that parallelizes across image tiles — but SharedArrayBuffer requires cross-origin isolation, which means shipping COOP/COEP headers on every page of your site. That is a site-wide security-header change affecting every third-party script, and in our test setup the isolated path could not be exercised by end-to-end tests at all. We ship the single-threaded encoder on purpose and kept the multithreaded variant as a documented, measured option.