Frontend
Ask canvas for a WebP in Safari and you silently get a PNG
Harshit Katheria Dev.to (EN Zone)
1 views
Every browser-based WebP converter I looked at is built on one line:
canvas.toBlob(blob => { /* ... */ }, 'image/webp', quality)
It's a reasonable place to start. It also has a failure mode that took me an embarrassingly long time to notice.
The silent fallback
Safari has never supported WebP encoding via canvas. It decodes WebP fine — that landed in Safari 14 — but it will not encode one.
Here's the part that matters: toBlob doesn't throw, and it doesn't return null. The HTML spec says that if the requested type isn't supported, the browser falls back to image/png. So on Safari, that line above hands you a perfectly valid PNG. If you named the download photo.webp — and why wouldn't you, you asked for a WebP — you have just shipped a PNG with the wrong extension to a user who will not find out until something downstream rejects it.
No error. No warning. Just the wrong file.
The only way to know is to check what you got back:
const blob = await canvas.convertToBlob({ type: mime, quality: quality / 100 })
if (blob.type !== mime) {
throw new Error(
`This browser cannot encode ${format} via canvas. ` +
`Safari does not support it — the WebAssembly encoder is required.`
)
}
That check is the whole reason the rest of this post exists. Once you accept that canvas can't be trusted for encoding, you end up shipping the codec yourself — libwebp, compiled to WebAssembly. Which is where the interesting problems start.
Here are the six that cost me the most time.
1. Vite's optimizeDeps needs two opposite fixes at once
This one is genuinely non-obvious, because exclude and include solve different problems and you need both.
optimizeDeps: {
exclude: ['@jsquash/webp/encode', '@jsquash/webp/decode', /* ... */],
include: ['utif2', 'libheif-js/wasm-bundle', 'client-zip'],
}
exclude is for the jSquash codecs. Vite's dependency pre-bundler rewrites their Emscripten glue code, and the rewritten glue can no longer resolve its sibling .wasm file. Pre-bundling breaks them, so you opt out.
include is for utif2 (TIFF) and libheif-js (HEIC). These are CommonJS, and pre-bundling is the exact step that converts CJS to ESM for the browser. Opt out of it and you get UTIF.decode is not a function at runtime.
So one list says "don't touch these" and the other says "you must touch these," and if you reach for the wrong one you get a broken build either way. The distinguishing question is whether the package loads a separate .wasm file at runtime — jSquash does, and libheif-js/wasm-bundle inlines it, which is why pre-bundling is safe there.
One more trap: Vite matches on the exact specifier. Listing @jsquash/webp does not cover @jsquash/webp/encode. Every subpath you import has to be listed on its own.
2. Dynamic imports inside a worker are invisible to the dep scanner
This is the bug that made me want to throw my laptop.
The codecs are loaded lazily, inside the worker, so a user converting a JPEG never downloads the 2 MB HEIC decoder:
case 'heic': {
const mod = await import('libheif-js/wasm-bundle')
// ...
}
Vite's startup dependency scan crawls your source graph. It does not crawl dynamic imports inside workers. So in dev, these packages are undeclared until the moment someone actually converts a file — at which point Vite discovers a new dependency, re-optimizes mid-session, and forces a full page reload.
From the user's side: you drop 50 files, hit convert, and the page reloads and throws away your entire queue. The console says 504 (Outdated Optimize Dep), which is not an obvious description of what just happened.
The fix is listing them in optimizeDeps so Vite resolves everything at startup instead of discovering it later. Which loops back to problem #1 — and note that the two lists disagree about which fix each package needs.
3. target_size does nothing unless you also raise the pass count
libwebp can hit a byte budget natively. You want a 200 KB file, you say so, and the encoder gets you there — no re-encoding at a dozen qualities and bisecting.
{
target_size: s.targetSize > 0 ? s.targetSize : 0,
pass: s.targetSize > 0 ? 6 : 1,
}
The catch is that libwebp converges on a byte budget through repeated entropy analysis. At the default of a single pass there's nothing to converge across, so target_size is silently ignored. You get a normal quality-based encode and no indication that the setting you carefully wired up did nothing at all.
Raising pass costs real time, so it's only worth paying when a budget is actually set — hence the conditional.
4. JPEG has no alpha, and nobody agrees what to do about it
I shipped a bug here, so let me describe it precisely.
Convert a transparent PNG to JPEG. JPEG has no alpha channel, so something has to happen to those pixels. What happens is worse than you'd guess, and it's differently wrong on each path:
mozjpeg (via @jsquash/jpeg) is handed the raw RGBA buffer and simply ignores every fourth byte. Canvas gives fully-transparent pixels an RGB of 0,0,0 — so transparent areas come out black. Semi-transparent pixels are encoded at full opacity, alpha discarded entirely.
canvas is no better, and this one is mandated: the HTML spec says a context with alpha is composited onto solid black when encoded to a format without it.
So both paths independently produce black splotches. Neither errors. You just get a corrupted-looking image.
Every desktop editor composites onto white, so match that, and do it before the encoder ever sees the buffer:
function flattenOntoWhite(imageData) {
const px = imageData.data
for (let i = 3; i < px.length; i += 4) {
const alpha = px[i]
if (alpha === 255) continue // opaque images pay ~nothing
const inv = 255 - alpha
px[i - 3] = (px[i - 3] * alpha + 255 * inv) / 255
px[i - 2] = (px[i - 2] * alpha + 255 * inv) / 255
px[i - 1] = (px[i - 1] * alpha + 255 * inv) / 255
px[i] = 255
}
}
Two details worth stealing. ImageData is not premultiplied, so the straight source-over form applies. And px is a Uint8ClampedArray, which rounds to nearest on assignment — so if you add + 127 to round manually, as I did in my first attempt, you round twice and bias every semi-transparent pixel upward.
5. SVG can't go through a worker
createImageBitmap accepts a Blob, which felt like the obvious way to get every format into the worker pool. It accepts bitmap formats only. Hand it an SVG blob and it fails.
Rasterizing an SVG needs an actual <img> element, and <img> only exists on the main thread. So SVG gets a separate path that rasterizes up front and posts the finished ImageData into the worker for encoding.
Related, and worth knowing before you file a bug against your own converter: an SVG rendered inside an <img> runs sandboxed. No external images, no remote stylesheets, no scripts. Anything referenced by URL simply doesn't draw. That's long-standing browser security behaviour, not something you can configure away — embed assets as data URIs instead.
6. A fallback that lies is worse than no fallback
The tempting design is a canvas fallback that catches everything, so nothing ever hard-fails.
Don't. On Safari, "catching everything" means silently returning a PNG named .webp — problem #1 again, now wrapped in a layer that makes it look intentional. The fallback is deliberately scoped to Chrome and Firefox, and it throws on Safari, where the WASM encoder is not optional.
Failing loudly beats producing a file that's quietly wrong.
The part that surprised me
I expected the hard problem to be the codecs. It wasn't — jSquash is good, and libwebp does the actual work.
The hard problem was that nearly every failure here is silent. Safari hands back the wrong format. target_size ignores you. mozjpeg drops your alpha channel. Vite discovers a dependency and wipes the queue. Not one of these throws an error that says what went wrong, and several produce a plausible-looking output file that's subtly incorrect.
If you're building something in this space, budget your time accordingly: not for writing the conversion, but for verifying that what came out is actually what you asked for. I now check the returned MIME type, check pixel values after encoding, and test against a production build rather than the dev server — because the dep optimizer and Rollup fail in different ways, and only one of them shows up in npm run dev.
The converter is freeconvertwebp.com — everything runs client-side, no upload. Happy to answer questions about any of this in the comments.
Read original: https://dev.to/harshit_katheria/ask-canvas-for-a-webp-in-safari-and-you-silently-get-a-png-5hn6
← Previous
Why You Shouldn’t Store Large Files in Your Database: Database vs. Amazon S3
Next →
The Complete Guide to Agent-to-Agent Marketplaces in 2026
Related
I built a compiler so I could stop writing custom element boilerplate
Frontend
2
DEV Community
RepoRoad: A Cosy Lofi Drive That Puts Open Source on the Map
Frontend
0
DEV Community
Designing a 5-band parametric EQ from the biquad up, in MATLAB
Frontend
8
Dev.to (EN Zone)
WanderJournal: A digital travelling Journal
Frontend
4
Dev.to (EN Zone)
Comments0
No comments yet — be the first