<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Harshit Katheria</title>
    <description>The latest articles on DEV Community by Harshit Katheria (@harshit_katheria).</description>
    <link>https://dev.to/harshit_katheria</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4113132%2F4cce613e-fe8f-4f68-bf9d-05e5177ffcab.png</url>
      <title>DEV Community: Harshit Katheria</title>
      <link>https://dev.to/harshit_katheria</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/harshit_katheria"/>
    <language>en</language>
    <item>
      <title>Ask canvas for a WebP in Safari and you silently get a PNG</title>
      <dc:creator>Harshit Katheria</dc:creator>
      <pubDate>Mon, 07 Sep 2026 05:08:30 +0000</pubDate>
      <link>https://dev.to/harshit_katheria/ask-canvas-for-a-webp-in-safari-and-you-silently-get-a-png-5hn6</link>
      <guid>https://dev.to/harshit_katheria/ask-canvas-for-a-webp-in-safari-and-you-silently-get-a-png-5hn6</guid>
      <description>&lt;p&gt;Every browser-based WebP converter I looked at is built on one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a reasonable place to start. It also has a failure mode that took me an embarrassingly long time to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent fallback
&lt;/h2&gt;

&lt;p&gt;Safari has never supported WebP &lt;em&gt;encoding&lt;/em&gt; via canvas. It decodes WebP fine — that landed in Safari 14 — but it will not encode one.&lt;/p&gt;

&lt;p&gt;Here's the part that matters: &lt;code&gt;toBlob&lt;/code&gt; 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 &lt;code&gt;image/png&lt;/code&gt;. So on Safari, that line above hands you a perfectly valid PNG. If you named the download &lt;code&gt;photo.webp&lt;/code&gt; — 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.&lt;/p&gt;

&lt;p&gt;No error. No warning. Just the wrong file.&lt;/p&gt;

&lt;p&gt;The only way to know is to check what you got back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;convertToBlob&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;mime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`This browser cannot encode &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; via canvas. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`Safari does not support it — the WebAssembly encoder is required.`&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here are the six that cost me the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Vite's &lt;code&gt;optimizeDeps&lt;/code&gt; needs two opposite fixes at once
&lt;/h2&gt;

&lt;p&gt;This one is genuinely non-obvious, because &lt;code&gt;exclude&lt;/code&gt; and &lt;code&gt;include&lt;/code&gt; solve &lt;em&gt;different&lt;/em&gt; problems and you need both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;optimizeDeps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@jsquash/webp/encode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@jsquash/webp/decode&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utif2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libheif-js/wasm-bundle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;client-zip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;exclude&lt;/code&gt;&lt;/strong&gt; 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 &lt;code&gt;.wasm&lt;/code&gt; file. Pre-bundling breaks them, so you opt out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;include&lt;/code&gt;&lt;/strong&gt; is for &lt;code&gt;utif2&lt;/code&gt; (TIFF) and &lt;code&gt;libheif-js&lt;/code&gt; (HEIC). These are CommonJS, and pre-bundling is the exact step that converts CJS to ESM for the browser. Opt &lt;em&gt;out&lt;/em&gt; of it and you get &lt;code&gt;UTIF.decode is not a function&lt;/code&gt; at runtime.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;.wasm&lt;/code&gt; file at runtime — jSquash does, and &lt;code&gt;libheif-js/wasm-bundle&lt;/code&gt; inlines it, which is why pre-bundling is safe there.&lt;/p&gt;

&lt;p&gt;One more trap: &lt;strong&gt;Vite matches on the exact specifier.&lt;/strong&gt; Listing &lt;code&gt;@jsquash/webp&lt;/code&gt; does &lt;em&gt;not&lt;/em&gt; cover &lt;code&gt;@jsquash/webp/encode&lt;/code&gt;. Every subpath you import has to be listed on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Dynamic imports inside a worker are invisible to the dep scanner
&lt;/h2&gt;

&lt;p&gt;This is the bug that made me want to throw my laptop.&lt;/p&gt;

&lt;p&gt;The codecs are loaded lazily, inside the worker, so a user converting a JPEG never downloads the 2 MB HEIC decoder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mod&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libheif-js/wasm-bundle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;From the user's side: you drop 50 files, hit convert, and the page reloads and throws away your entire queue. The console says &lt;code&gt;504 (Outdated Optimize Dep)&lt;/code&gt;, which is not an obvious description of what just happened.&lt;/p&gt;

&lt;p&gt;The fix is listing them in &lt;code&gt;optimizeDeps&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;target_size&lt;/code&gt; does nothing unless you also raise the pass count
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;target_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;pass&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targetSize&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is that libwebp converges on a byte budget through &lt;em&gt;repeated entropy analysis&lt;/em&gt;. At the default of a single pass there's nothing to converge across, so &lt;code&gt;target_size&lt;/code&gt; is silently ignored. You get a normal quality-based encode and no indication that the setting you carefully wired up did nothing at all.&lt;/p&gt;

&lt;p&gt;Raising &lt;code&gt;pass&lt;/code&gt; costs real time, so it's only worth paying when a budget is actually set — hence the conditional.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. JPEG has no alpha, and nobody agrees what to do about it
&lt;/h2&gt;

&lt;p&gt;I shipped a bug here, so let me describe it precisely.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;mozjpeg&lt;/strong&gt; (via &lt;code&gt;@jsquash/jpeg&lt;/code&gt;) is handed the raw RGBA buffer and simply ignores every fourth byte. Canvas gives fully-transparent pixels an RGB of &lt;code&gt;0,0,0&lt;/code&gt; — so transparent areas come out &lt;strong&gt;black&lt;/strong&gt;. Semi-transparent pixels are encoded at full opacity, alpha discarded entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;canvas&lt;/strong&gt; is no better, and this one is mandated: the HTML spec says a context with alpha is composited onto solid &lt;strong&gt;black&lt;/strong&gt; when encoded to a format without it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So both paths independently produce black splotches. Neither errors. You just get a corrupted-looking image.&lt;/p&gt;

&lt;p&gt;Every desktop editor composites onto white, so match that, and do it before the encoder ever sees the buffer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flattenOntoWhite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;imageData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;          &lt;span class="c1"&gt;// opaque images pay ~nothing&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt;
    &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
    &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
    &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;inv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
    &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth stealing. &lt;code&gt;ImageData&lt;/code&gt; is &lt;strong&gt;not&lt;/strong&gt; premultiplied, so the straight source-over form applies. And &lt;code&gt;px&lt;/code&gt; is a &lt;code&gt;Uint8ClampedArray&lt;/code&gt;, which rounds to nearest on assignment — so if you add &lt;code&gt;+ 127&lt;/code&gt; to round manually, as I did in my first attempt, you round twice and bias every semi-transparent pixel upward.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. SVG can't go through a worker
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createImageBitmap&lt;/code&gt; accepts a &lt;code&gt;Blob&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Rasterizing an SVG needs an actual &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element, and &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; only exists on the main thread. So SVG gets a separate path that rasterizes up front and posts the finished &lt;code&gt;ImageData&lt;/code&gt; into the worker for encoding.&lt;/p&gt;

&lt;p&gt;Related, and worth knowing before you file a bug against your own converter: an SVG rendered inside an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A fallback that lies is worse than no fallback
&lt;/h2&gt;

&lt;p&gt;The tempting design is a canvas fallback that catches everything, so nothing ever hard-fails.&lt;/p&gt;

&lt;p&gt;Don't. On Safari, "catching everything" means silently returning a PNG named &lt;code&gt;.webp&lt;/code&gt; — 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.&lt;/p&gt;

&lt;p&gt;Failing loudly beats producing a file that's quietly wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that surprised me
&lt;/h2&gt;

&lt;p&gt;I expected the hard problem to be the codecs. It wasn't — jSquash is good, and libwebp does the actual work.&lt;/p&gt;

&lt;p&gt;The hard problem was that &lt;strong&gt;nearly every failure here is silent&lt;/strong&gt;. Safari hands back the wrong format. &lt;code&gt;target_size&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;npm run dev&lt;/code&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The converter is &lt;a href="https://freeconvertwebp.com" rel="noopener noreferrer"&gt;freeconvertwebp.com&lt;/a&gt; — everything runs client-side, no upload. Happy to answer questions about any of this in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>javascript</category>
      <category>vite</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
