<?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: Jaydip Patel</title>
    <description>The latest articles on DEV Community by Jaydip Patel (@jaydippatel83).</description>
    <link>https://dev.to/jaydippatel83</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%2F1175017%2Fe6ff1088-55d2-44b7-9381-f93895c2b919.jpeg</url>
      <title>DEV Community: Jaydip Patel</title>
      <link>https://dev.to/jaydippatel83</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jaydippatel83"/>
    <language>en</language>
    <item>
      <title>I built 62 image tools that never upload your photos. Here's what was harder than expected.</title>
      <dc:creator>Jaydip Patel</dc:creator>
      <pubDate>Mon, 05 Oct 2026 05:49:02 +0000</pubDate>
      <link>https://dev.to/jaydippatel83/i-built-62-image-tools-that-never-upload-your-photos-heres-what-was-harder-than-expected-3nhn</link>
      <guid>https://dev.to/jaydippatel83/i-built-62-image-tools-that-never-upload-your-photos-heres-what-was-harder-than-expected-3nhn</guid>
      <description>&lt;p&gt;I do a lot of SEO and web work, which means I touch images every single day. Compress this one for a page, remove the background from that one, convert a HEIC someone sent me, resize something for a blog post.&lt;/p&gt;

&lt;p&gt;And every day it was the same routine. One site for compressing, another for background removal, and another for converting. Half of them hit me with "upgrade to Pro" after one or two images. The rest were so full of ads I had to hunt for the actual download button. Some wanted me to sign up just to get my own file back. And all of them wanted me to upload the image to their server first, for something my laptop can easily do on its own.&lt;/p&gt;

&lt;p&gt;At some point I got tired of it and started building my own. That became ImgKit: a set of image tools (compress, resize, HEIC to JPG, remove background, passport photos, that kind of stuff) with no upgrade wall, no ads, no account, and nothing uploaded. Everything runs in your browser tab. It's at 62 tools now, which is more than I planned, and I built most of it with Claude Code.&lt;/p&gt;

&lt;p&gt;This post isn't an ad. It's what I learned building it, because a lot of it surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup is boring on purpose.
&lt;/h2&gt;

&lt;p&gt;It's a Next.js static export on Vercel. No backend, no database, no API routes. That was the whole point: if there's no server, there's nowhere for your photo to go. It also means hosting costs me basically nothing.&lt;/p&gt;

&lt;p&gt;The tradeoff is that everything has to work in the browser, including stuff you'd normally throw at a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Compress to exactly 50 KB" sounds easy. It isn't.
&lt;/h2&gt;

&lt;p&gt;This was the feature I thought would take an afternoon.&lt;/p&gt;

&lt;p&gt;Canvas has &lt;code&gt;toBlob(type, quality)&lt;/code&gt;, so you just lower the quality until the file fits, right? Except quality doesn't map to file size in any predictable way. The same quality setting gives you 30 KB on one photo and 400 KB on another.&lt;/p&gt;

&lt;p&gt;What I ended up with is a binary search over quality. Encode, check the size, go up or down, repeat. Seven steps gets you within about 1% of the best quality that still fits. And sometimes no quality fits, because the photo is just too big in pixels. In that case it scales the image down a bit and searches again.&lt;/p&gt;

&lt;p&gt;Real example from my own testing: an 858 KB waterfall photo, target 50 KB. It came out at 43.2 KB, quality 17%, resized from 1600×1067 to 1372×915. Users see that note on the result, so nobody gets surprised that their image got smaller in pixels too.&lt;/p&gt;

&lt;p&gt;The other thing: &lt;code&gt;canvas.toBlob&lt;/code&gt; is not a great JPEG encoder. I switched the heavy lifting to WebAssembly builds of mozjpeg, oxipng and an AVIF encoder (the jSquash packages). Same quality, noticeably smaller files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running AI models in a browser tab
&lt;/h2&gt;

&lt;p&gt;This was the fun part, and the part with the most surprises.&lt;/p&gt;

&lt;p&gt;Background removal, upscaling, object removal and OCR all run locally with transformers.js and onnxruntime-web. The model downloads once, gets cached, and after that it works offline. Some numbers from testing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Background removal uses MODNet, about 13 MB. First run includes the download. After that it's a few seconds on a normal laptop.&lt;/li&gt;
&lt;li&gt;Object removal uses MI-GAN, 28 MB, and it's honestly fast. Under a second.&lt;/li&gt;
&lt;li&gt;The upscaler is the slow one. On a laptop without a usable GPU it can take a minute, because it's all on the CPU. I had to cap input sizes and process in tiles so it doesn't freeze the tab.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two things I didn't see coming:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WASM is single threaded for me.&lt;/strong&gt; Multi-threaded WebAssembly needs special headers (COOP/COEP), and those break a bunch of other things on a static site. So every AI model runs on one thread unless the browser has WebGPU. That's the main reason some tools feel fast on one machine and slow on another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Library versions matter a lot.&lt;/strong&gt; I had to pin transformers.js to 3.7.5. The newer version's onnxruntime build just crashed for my models. No useful error, it just died.&lt;/p&gt;

&lt;p&gt;For finding text in photos (for the "remove text from image" tool), I first tried Tesseract. It was bad at it. It missed white text on a light wall and "found" words in bicycle spokes. Switching to PaddleOCR's text detector (4.7 MB) fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HEIC bug I didn't know I had
&lt;/h2&gt;

&lt;p&gt;iPhones save photos as HEIC, which Windows still can't open by default. HEIC to JPG is one of the most used tools.&lt;/p&gt;

&lt;p&gt;I was using a popular library called heic2any. Last week a security scanner flagged my site because my Content Security Policy allowed &lt;code&gt;unsafe-eval&lt;/code&gt;. I dug in, and that permission was only there because heic2any compiles code at runtime. So I swapped it for heic-to, which has a build that doesn't need eval.&lt;/p&gt;

&lt;p&gt;Then I compared the output of both, just to make sure nothing changed. Something did change. The old library had been making photos darker the whole time. Shadows crushed, reds too strong.&lt;/p&gt;

&lt;p&gt;I measured how close each output was to the original photo (PSNR, higher is better):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Photo&lt;/th&gt;
&lt;th&gt;Old library&lt;/th&gt;
&lt;th&gt;New library&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Waterfall&lt;/td&gt;
&lt;td&gt;27.0 dB&lt;/td&gt;
&lt;td&gt;36.9 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portrait&lt;/td&gt;
&lt;td&gt;26.7 dB&lt;/td&gt;
&lt;td&gt;42.7 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Another photo&lt;/td&gt;
&lt;td&gt;27.2 dB&lt;/td&gt;
&lt;td&gt;38.7 dB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So a security fix ended up being a quality fix too. Nobody had complained, which probably just means people assumed that's how HEIC conversion looks.&lt;/p&gt;

&lt;p&gt;While I was at it, I found one more &lt;code&gt;Function(...)&lt;/code&gt; call that would've broken under a strict CSP. It was a feature check inside a Node &lt;code&gt;util&lt;/code&gt; polyfill that JSZip pulls in. Using JSZip's prebuilt browser bundle instead got rid of it, and now JSZip only loads when someone actually downloads a ZIP.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that were invisible
&lt;/h2&gt;

&lt;p&gt;AI-assisted coding got features working fast. The things that bit me were the ones you can't see by clicking around.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I spammed Bing without knowing it.&lt;/strong&gt; I set up IndexNow, which tells Bing when a page changes. My site has 113 pages. Bing's report showed 4,600 submissions. The sidebar lists every tool, so every time I added one tool, every page's content "changed", and the script resubmitted all of them. Same with my sitemap's lastmod dates: I was telling search engines 100 pages updated when really 2 did. The fix was simple once I saw it: ignore the header, nav, sidebar and footer when deciding whether a page changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backlinks are mostly fake.&lt;/strong&gt; Ahrefs showed 359 sites linking to me after a month. About 5 are real. The rest are domains like &lt;code&gt;link-baron-syndicate-equity.store&lt;/code&gt;. That's apparently normal for new sites, but it was funny to see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing still has 0 of my pages indexed.&lt;/strong&gt; Google started showing the site within days. Bing lists my pages as "discovered, not crawled". Someone on X told me that's crawl priority for a new domain, and the fix is real links, not more pings. I think they're right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth doing it all in the browser?
&lt;/h2&gt;

&lt;p&gt;For this kind of tool, yes. The image is already on your device. Uploading it somewhere just to shrink it never made much sense to me.&lt;/p&gt;

&lt;p&gt;It's not free though. You're limited by the user's hardware, big models need a download, and you can't fix a slow CPU from your side. If I needed collaboration or storage, I'd want a server.&lt;/p&gt;

&lt;p&gt;You can check the privacy claim yourself, by the way: open DevTools, go to the Network tab, run a tool, and watch. Nothing goes out except the model downloads. Or just turn off your Wi-Fi after the page loads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Honestly, less building and more talking to people. I built 62 tools and then realized I don't know which 5 people actually need. If you try it and something's missing or broken, I'd really like to hear it.&lt;/p&gt;

&lt;p&gt;It's free, no account: &lt;a href="https://imgkit.xyz" rel="noopener noreferrer"&gt;imgkit.xyz&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've run ML models in the browser, I'm curious what your experience was with WebGPU vs plain WASM. That's the next thing I want to figure out.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webassembly</category>
      <category>privacy</category>
    </item>
    <item>
      <title>How to compress an image to an exact file size in the browser</title>
      <dc:creator>Jaydip Patel</dc:creator>
      <pubDate>Sat, 26 Sep 2026 13:48:55 +0000</pubDate>
      <link>https://dev.to/jaydippatel83/how-to-compress-an-image-to-an-exact-file-size-in-the-browser-2a2</link>
      <guid>https://dev.to/jaydippatel83/how-to-compress-an-image-to-an-exact-file-size-in-the-browser-2a2</guid>
      <description>&lt;p&gt;Upload forms don't ask for "quality 70". They ask for "under 50 KB". Exam portals, visa applications and job sites all do it, and a phone photo is 3–5 MB, so the gap is a factor of fifty or more.&lt;/p&gt;

&lt;p&gt;A quality slider doesn't answer that question. There's no quality setting that means 50 KB, because the same setting gives a 30 KB file for a plain portrait and a 300 KB file for a busy street scene. You have to search for it.&lt;/p&gt;

&lt;p&gt;This is how I built exact-size compression in &lt;a href="https://imgkit.xyz" rel="noopener noreferrer"&gt;ImgKit&lt;/a&gt;, which runs entirely in the browser. Nothing here needs a server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: binary-search the quality
&lt;/h2&gt;

&lt;p&gt;For JPEG and WebP, file size goes up as quality goes up. It isn't perfectly smooth, but it's close enough that a binary search works: try the middle, keep whichever half still fits.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fitQuality&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;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxBytes&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;encode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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="p"&gt;)&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="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;q&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;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&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;best&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&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;best&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// quality alone can't get there&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;top&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;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hi&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;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxBytes&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;top&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// already fits at high quality&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;0&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="mi"&gt;7&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="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;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&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="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="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mid&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;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&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;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&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;best&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;Seven steps narrow a 0.1–0.95 range to about 1% of quality, which is well past the point anyone can see a difference. Two things are worth getting right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep the best blob that fit, not the last one tried.&lt;/strong&gt; The final midpoint is often just over the limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check both ends first.&lt;/strong&gt; If the lowest quality is too big, searching is pointless. If the highest already fits, there's no reason to throw quality away.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: when quality isn't enough, shrink the image
&lt;/h2&gt;

&lt;p&gt;A 12-megapixel photo at quality 10% can still be 400 KB. At that point the only lever left is the number of pixels.&lt;/p&gt;

&lt;p&gt;File size is roughly proportional to pixel count, and pixel count goes with the square of the side length. So if the smallest encode is 4× too big, shrink each side by about √(1/4) = ½:&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;factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;maxBytes&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;smallest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.92&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&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;factor&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&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;factor&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;0.92&lt;/code&gt; is a safety margin: the relationship isn't exact, and undershooting slightly means one resize instead of two. Then run the quality search again at the new size. Cap the number of attempts and stop at a sensible minimum side (I use 64 px), so an impossible target fails with a clear message instead of producing a thumbnail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: search with the fast encoder, finish with the good one
&lt;/h2&gt;

&lt;p&gt;The browser's built-in encoder (&lt;code&gt;canvas.toBlob&lt;/code&gt;) is fast but not very good. MozJPEG, compiled to WebAssembly, typically fits the same picture into 10–30% fewer bytes. It's also 15–25× slower. A search that has to resize a few times can run dozens of encodes, so doing all of it with MozJPEG would take several seconds per photo.&lt;/p&gt;

&lt;p&gt;So the search runs with the fast encoder, and only the final file uses the good one. That creates a subtle problem. Suppose the fast encoder found that quality 25% fits 50 KB. MozJPEG at 25% might come out at 38 KB, which leaves 12 KB of budget unspent. The user gets a worse-looking photo than necessary.&lt;/p&gt;

&lt;p&gt;The fix is a short second search, with the real encoder, upward from where the first one stopped:&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;let&lt;/span&gt; &lt;span class="nx"&gt;best&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;mozjpeg&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;bestQuality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// same quality, fewer bytes&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bestQuality&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&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;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;0&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="mi"&gt;4&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="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;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;candidate&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;mozjpeg&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;mid&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;candidate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxBytes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&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;Four extra encodes costs about 0.3 seconds and spends the leftover budget on quality. The file still fits, and it looks as good as the limit allows. I use &lt;a href="https://github.com/jamsinclair/jSquash" rel="noopener noreferrer"&gt;jSquash&lt;/a&gt; for the WebAssembly builds of MozJPEG and OxiPNG. They're the same encoders Squoosh uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The details that bite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transparency turns black.&lt;/strong&gt; JPEG has no alpha channel, so a transparent PNG drawn onto a canvas and saved as JPEG gets black where it was clear. Fill the canvas with white before drawing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS Safari has a canvas size limit&lt;/strong&gt; of roughly 16.7 megapixels. A big photo just fails to get a context. Check for that and start from a scaled-down size instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The original may already fit.&lt;/strong&gt; If the file is under the target and the user didn't ask for a format change, return it untouched. Re-encoding a small JPEG can make it bigger and always costs a little quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HEIC needs decoding first.&lt;/strong&gt; Browsers other than Safari can't draw HEIC, so iPhone photos need a decode step before any of this starts. I use &lt;a href="https://github.com/alexcorvi/heic2any" rel="noopener noreferrer"&gt;heic2any&lt;/a&gt;, a JavaScript build of libheif.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;The file lands just under the limit rather than well below it, so none of the size budget is wasted. Everything happens in the tab, so it works offline and the photo never leaves the device.&lt;/p&gt;

&lt;p&gt;If you want to see it working, it's the &lt;a href="https://imgkit.xyz/compress-image-to-100kb" rel="noopener noreferrer"&gt;compress to 100 KB tool&lt;/a&gt; on ImgKit. I built it, so treat the plug accordingly. The approach works for any encoder that takes a quality setting, and the whole thing is about 200 lines.&lt;/p&gt;

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