<?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: glebr2d2</title>
    <description>The latest articles on DEV Community by glebr2d2 (@glebr2d2).</description>
    <link>https://dev.to/glebr2d2</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3860109%2F7c8b937a-d42d-4022-aba4-2cc00015259d.jpeg</url>
      <title>DEV Community: glebr2d2</title>
      <link>https://dev.to/glebr2d2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/glebr2d2"/>
    <language>en</language>
    <item>
      <title># How I Built a Client-Side HEIC Converter — No Server Required</title>
      <dc:creator>glebr2d2</dc:creator>
      <pubDate>Fri, 03 Apr 2026 21:32:21 +0000</pubDate>
      <link>https://dev.to/glebr2d2/-how-i-built-a-client-side-heic-converter-no-server-required-2fl4</link>
      <guid>https://dev.to/glebr2d2/-how-i-built-a-client-side-heic-converter-no-server-required-2fl4</guid>
      <description>&lt;p&gt;Every time someone asked me to "just send the photo as JPG," I died a little inside. iPhones have been saving photos as HEIC since iOS 11, and in 2026, the format mismatch is still a daily pain point for millions of people. Most online converters ask you to upload your personal photos to some random server. I wanted to build something better: a converter that runs entirely in your browser, with zero server involvement.&lt;/p&gt;

&lt;p&gt;Here's how I built it, what broke along the way, and what I learned about decoding Apple's image format with WebAssembly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Client-Side Matters
&lt;/h2&gt;

&lt;p&gt;The typical file converter architecture is straightforward: user uploads file, server processes it, server returns the result. It works, but it comes with baggage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt;: Your photos hit someone else's machine. For personal photos, that's a hard sell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server costs&lt;/strong&gt;: Image processing is CPU-intensive. At scale, you're paying real money for compute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: Upload + process + download vs. just... process. Locally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline capability&lt;/strong&gt;: A client-side converter works on a plane, in a coffee shop with bad wifi, anywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff is complexity. Browsers weren't designed to decode proprietary image formats. But WebAssembly changed that equation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How HEIC Decoding Works in the Browser
&lt;/h2&gt;

&lt;p&gt;HEIC (High Efficiency Image Container) is built on top of the HEVC video codec — the same tech used for 4K video. Browsers don't support it natively (except Safari, somewhat). So you need a decoder.&lt;/p&gt;

&lt;p&gt;The pipeline looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HEIC file (ArrayBuffer)
    → libheif (compiled to WASM via Emscripten)
    → Raw pixel data (RGBA)
    → Canvas API (draw pixels)
    → canvas.toBlob() → JPG/PNG Blob
    → Download link
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I used the &lt;a href="https://github.com/nicedoc/heic-to" rel="noopener noreferrer"&gt;&lt;code&gt;heic-to&lt;/code&gt;&lt;/a&gt; library, which wraps libheif's WASM build into a clean async API. The core conversion is surprisingly compact:&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;import&lt;/span&gt; &lt;span class="nx"&gt;HeicTo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heic-to&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;convertHeicToJpg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;heicFile&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="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// heic-to handles WASM loading, decoding, and Canvas rendering&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jpgBlob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;HeicTo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;heicFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// File or Blob&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// target format&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="c1"&gt;// 0.0 - 1.0&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;jpgBlob&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// standard Blob, ready to download&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, &lt;code&gt;heic-to&lt;/code&gt; loads a ~1.5MB WASM binary (libheif), decodes the HEIC container, extracts the HEVC-compressed image data, and renders the raw pixels onto an offscreen canvas. The Canvas API then does the format conversion via &lt;code&gt;toBlob()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For batch conversions with multiple files, I zip the results client-side using JSZip:&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;import&lt;/span&gt; &lt;span class="nx"&gt;JSZip&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jszip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;batchConvert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&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="nx"&gt;quality&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;zip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;JSZip&lt;/span&gt;&lt;span class="p"&gt;();&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;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&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;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nc"&gt;HeicTo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;to&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="nx"&gt;quality&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;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;heic$/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;format&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jpeg&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;jpg&lt;/span&gt;&lt;span class="dl"&gt;'&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;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;zipBlob&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;zip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateAsync&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// trigger download...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything stays in browser memory. No temporary files on a server, no cleanup jobs, no S3 buckets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenges Nobody Warns You About
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Color Space Mismatch
&lt;/h3&gt;

&lt;p&gt;This was the most insidious bug. iPhones shoot in Display P3 color space — a wider gamut than the sRGB that JPG typically assumes. When you decode HEIC and render to Canvas, the browser may or may not apply color management depending on the platform.&lt;/p&gt;

&lt;p&gt;The result: photos that look subtly different after conversion. Slightly desaturated greens, shifted skin tones. Not wrong enough to notice immediately, but wrong enough to bother a photographer.&lt;/p&gt;

&lt;p&gt;My solution was an optional sRGB normalization pass. After initial decoding, I re-draw through a Canvas context to force sRGB interpretation:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;convertToSrgb&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;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&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;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&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="mi"&gt;0&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;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`image/&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;`&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;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;It's not perfect color management, but it handles the 90% case. I exposed it as a toggle so users can decide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Memory Limits on Large Files
&lt;/h3&gt;

&lt;p&gt;A 48MP iPhone photo in HEIC might be 8-12MB compressed. Decoded to raw RGBA pixels, that's &lt;code&gt;8064 × 6048 × 4 bytes ≈ 195MB&lt;/code&gt; in memory. Multiply that by a batch of 20 photos, and you're pushing browsers to their limits.&lt;/p&gt;

&lt;p&gt;I process files sequentially rather than in parallel. It's slower, but it avoids the "Aw, Snap!" crash that Chrome shows when you blow through its memory ceiling. For files above ~80MB, I recommend the Chrome extension, which has slightly more generous resource limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  Browser Compatibility
&lt;/h3&gt;

&lt;p&gt;The WASM-based decoder works in all modern browsers — Chrome, Firefox, Edge, Safari 15+. But I hit edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safari on iOS&lt;/strong&gt;: Intermittent failures with certain HEIC variants that use tiling (multi-image containers). The same files decode fine on macOS Safari.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firefox&lt;/strong&gt;: Slightly slower WASM execution compared to Chrome's V8 engine, but functionally identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Older browsers&lt;/strong&gt;: No WASM = no conversion. I show a clear error rather than failing silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Chrome Extension Angle
&lt;/h2&gt;

&lt;p&gt;After building the web version, packaging it as a Chrome extension was a natural next step. The core conversion logic is identical — same &lt;code&gt;heic-to&lt;/code&gt; library, same Canvas pipeline. The extension adds convenience: right-click context menu integration, persistent settings, and one fewer tab to keep open.&lt;/p&gt;

&lt;p&gt;The extension runs under Manifest V3 with a service worker background script. One quirk: Chrome extensions have stricter CSP (Content Security Policy) than regular web pages, so all WASM loading has to go through &lt;code&gt;'self'&lt;/code&gt; — no CDN loading. I bundle everything locally.&lt;/p&gt;

&lt;p&gt;You can try both the web converter and the extension at &lt;a href="https://www.convert.rocks/heic-to-jpg/" rel="noopener noreferrer"&gt;convert.rocks&lt;/a&gt; — the web version is free, no sign-up, and yes, you can disconnect your internet after loading the page to verify nothing gets uploaded.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with WebP output sooner.&lt;/strong&gt; JPG and PNG were the obvious first targets, but WebP gives you better compression than JPG with better quality. I added it later using &lt;code&gt;webp-converter-browser&lt;/code&gt;, but it requires an extra conversion step (HEIC → PNG → WebP) that I'd architect differently from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;@jsquash&lt;/code&gt; for modern format support.&lt;/strong&gt; The &lt;a href="https://github.com/nicedoc/jSquash" rel="noopener noreferrer"&gt;jSquash&lt;/a&gt; project provides modular WASM codecs for AVIF, JXL, and WebP with much smaller bundles (~10KB-2MB per codec vs. monolithic builds). For the next phase, I'm adding AVIF encoding and decoding — it's the next "HEIC moment" as Android and web platforms push adoption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider OffscreenCanvas and Web Workers.&lt;/strong&gt; Currently, conversion blocks the main thread during the Canvas rendering step. For large files, the UI freezes for 1-2 seconds. Moving the Canvas pipeline to a Web Worker with &lt;code&gt;OffscreenCanvas&lt;/code&gt; would keep the UI responsive. It's well-supported now but requires restructuring the conversion flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Progressive rendering for batch jobs.&lt;/strong&gt; Right now, all files convert before any results appear. A streaming approach — showing each converted file as it finishes — would feel much faster even if total time is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;The entire converter is ~15KB of application JavaScript plus ~1.5MB of WASM (loaded on demand). No framework, no build step for the web version — just vanilla JS, a CDN-loaded library, and the Canvas API. It loads in under 2 seconds on a 3G connection, and conversion takes 1-3 seconds per photo depending on resolution.&lt;/p&gt;

&lt;p&gt;For an image format that causes daily frustration for millions of iPhone users sharing photos with Windows and Android users, a 15KB solution that runs in the browser feels like the right level of complexity.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you're building browser-based file tools and want to compare notes, find me in the comments. I'm currently working on AVIF and JXL converters using the same client-side architecture.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webassembly</category>
      <category>tutorial</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
