<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[WeryAI Engineering]]></title><description><![CDATA[Engineering notes on small browser tools, image compression, Web Workers, and Chrome extensions from WeryAI.]]></description><link>https://weryai.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad74881eb24f8e8bafd0314/5a98329c-2595-4c69-b2e8-212a90b373bd.png</url><title>WeryAI Engineering</title><link>https://weryai.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 16:00:52 GMT</lastBuildDate><atom:link href="https://weryai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How we built a browser image compressor that never makes files bigger]]></title><description><![CDATA[Canvas encoding, original-file fallback, bounded Web Workers, ZIP downloads, and MV3 permissions in a browser image compressor.]]></description><link>https://weryai.hashnode.dev/how-we-built-a-browser-image-compressor-that-never-makes-files-bigger</link><guid isPermaLink="true">https://weryai.hashnode.dev/how-we-built-a-browser-image-compressor-that-never-makes-files-bigger</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[WeryAI]]></dc:creator><pubDate>Tue, 20 Oct 2026 11:08:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad74881eb24f8e8bafd0314/21337a40-e57f-4b9c-8282-a22b160f9f39.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Choosing a JPEG quality value is easy. Keeping a promise about the downloaded file takes more work.</p>
<p>Our compressor accepts a batch, starts processing immediately, and returns either a smaller encoding or the original file. That last option matters: an image that has already been optimized should not become larger because someone ran it through another tool.</p>
<p>The implementation is plain JavaScript: canvas encoders, a bounded Web Worker pool, and JSZip for batch downloads. The same compression engine runs in a website and a Manifest V3 Chrome extension. Here are the decisions and failure cases behind it.</p>
<h2>1. Keep the compression path on the device</h2>
<p>For this utility, a server would add an upload, a second copy of the image, and another failure boundary. Browser APIs can already decode common image formats and encode a canvas. We keep the selected files as File and Blob objects and generate downloads from their results.</p>
<p>Once the application and its bundled assets have loaded, the JPG, PNG, and WebP compression path does not need a network request. That is a runtime property, not a promise that an uncached page can always be opened offline.</p>
<p>There are two exceptions worth separating. The website loads a pinned HEIC decoder when someone first imports HEIC; that request fetches decoder code, not the image. The extension also has a separate, explicit AI editing action that passes the selected image to an external editor. Neither action is part of ordinary local compression. The extension does not bundle the HEIC decoder.</p>
<p>Keeping those boundaries explicit made the privacy explanation easier to check against the code.</p>
<h2>2. Treat an encoding as a candidate</h2>
<p>We decode the input with createImageBitmap, draw it at the requested dimensions, and ask a canvas encoder for an output Blob. The core prefers OffscreenCanvas; its alternative creates an HTML canvas. Encoding uses convertToBlob when available and toBlob otherwise.</p>
<p>The automatic format policy is small enough to read in full:</p>
<pre><code class="language-javascript">function candidateTypes(srcType, alpha, format) {
  if (format &amp;&amp; format !== 'auto') return [format];
  if (srcType === 'image/png') return alpha ? ['image/webp'] : ['image/webp', 'image/jpeg'];
  if (srcType === 'image/webp') return ['image/webp'];
  return ['image/jpeg'];
}
</code></pre>
<p>An opaque PNG gets WebP and JPEG candidates, and the smaller encoded Blob wins. A transparent PNG gets a WebP candidate instead of silently flattening its background. WebP stays WebP in automatic mode; JPEG follows the JPEG path. An explicitly selected output format overrides this candidate list.</p>
<p>PNG output is also available explicitly, but a JPEG-style quality parameter is not a useful control for its lossless encoder. We do not pretend that one quality slider has equivalent behavior across formats.</p>
<p>Before choosing candidates, the core checks every alpha value in relevant inputs. Sampling looked attractive until a fixture with one transparent pixel exposed the problem: missing that pixel could send a transparent image down the opaque JPEG path. The complete scan costs work, but it gives the format policy reliable information.</p>
<h2>3. Compare against the real original</h2>
<p>The size guarantee belongs after candidate selection. It must also apply after resizing, forced conversion, and preprocessing.</p>
<p>This is the actual return guard in the engine:</p>
<pre><code class="language-javascript">if (best.blob.size &gt;= original.size) {
  return { blob: original, type: originalType, width: originalWidth, height: originalHeight, kept: true,
    constraintsSkipped: !!(resized || forced || original !== file),
    targetMet: !(opts.targetKB &gt; 0) || original.size &lt;= opts.targetKB * 1024 };
}
</code></pre>
<p>Equal size counts as a reason to keep the original too. The result describes the file that will actually be downloaded, including its original dimensions and type.</p>
<p>This choice has a visible consequence. If a requested JPEG conversion or resize would produce more bytes, the user receives the original and an “Original kept” explanation. The tool cannot promise both “always convert to this format” and “never increase file size” for every input. We give the size rule priority and expose skipped constraints.</p>
<p>HEIC needed an additional correction. Its decoded JPEG is an intermediate, not the user's original. The wrapper passes the original HEIC file into the engine, so the final comparison uses those original bytes. Comparing only with the decoded JPEG would make the displayed savings misleading.</p>
<h2>4. Bound the workers, then package the results</h2>
<p>Each worker receives a file, options, and a job identifier. It returns either the result or an error carrying that identifier. The UI owns the queue and file rows.</p>
<p>The pool size is deliberately bounded:</p>
<pre><code class="language-javascript">var n = Math.min(4, Math.max(2, (navigator.hardwareConcurrency || 4) - 1));
try { for (var i = 0; i &lt; n; i++) this.pool.push({ w: new Worker(this.workerUrl), busy: false }); }
catch (e) { this.pool = []; }
</code></pre>
<p>That creates two to four workers rather than one per selected file. A free slot takes the next queued item. Completion updates its row, releases the slot, and pumps the queue again. When workers are unavailable, or the app is opened through file://, the code uses the main-thread path instead.</p>
<p>Moving pixel work off the UI thread helps responsiveness; it does not remove memory limits. A compressed file can decode into a much larger pixel buffer, and transparency checks read those pixels. Large batches still depend on the device.</p>
<p>Completed results are collected into a ZIP with the bundled JSZip library. Duplicate output names receive suffixes rather than overwriting each other. A single result downloads directly. A failed file remains an error row, instead of becoming a zero-byte “successful” download.</p>
<h2>5. Reuse the engine in a Chrome extension</h2>
<p>The extension opens the same compressor UI in a tab. Its right-click compression action passes the selected image URL to that tab; it does not compress inside the service worker.</p>
<p>Reading a remote image requires more care than reading a local File. The compression page initially shows an internal icon instead of loading the remote thumbnail immediately. Only after the user clicks “Compress it” does it request access to the source site and fetch the image. A denied request leaves an explanation and a manual-file alternative.</p>
<p>The current MV3 package also declares scripting and a fixed host permission for <code>www.wery.ai</code>. Those support the separate “Edit photo with AI” action, which places the chosen image into the editor's file input. They are not a hidden requirement of local canvas compression. Ordinary AI links only navigate; the explicit right-click editing action carries the image.</p>
<p>Theme selection is independent of both paths. Auto follows the browser's color preference; Light and Dark set an explicit document theme. A local preference survives refresh. The website continues to follow the browser automatically.</p>
<h2>6. Test the promise, not just a nice photograph</h2>
<p>Our project notes record an earlier five-image website run with a 56% reduction in combined file size. That is one input set, not a general compression ratio or a speed benchmark.</p>
<p>The screenshot below shows a different five-image batch with an 80% aggregate reduction. Its numbers should not be substituted for the earlier run.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8zmek5uu28274nhx0kqq.png" alt="A separate five-image batch in the extension, with individual output formats and a ZIP download button" /></p>
<p>For regression checks, the more useful inputs were the awkward ones. A 70-byte PNG returned unchanged. A transparent PNG with a single transparent pixel retained that pixel. A 24,163-byte HEIC returned as the original when the candidate was larger. An 85-byte PNG forced toward JPEG with smaller dimensions also returned its original bytes and dimensions.</p>
<p>Those cases catch failures that a large, easily compressed photo misses. We also unpacked the downloaded ZIP and compared retained originals byte for byte.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/zx5wkf50dmbem57qbzvk.png" alt="The before-and-after viewer for one image in the separate screenshot batch" /></p>
<p>The comparison viewer helps inspect visual tradeoffs. It is not evidence that a lossy re-encode is pixel-identical to its input.</p>
<h2>7. Leave the limits visible</h2>
<p>A target size is an attempt, not a guarantee. For JPEG and WebP targets, the implementation tries seven quality values per round and up to six rounds of progressively smaller dimensions. If that bounded search still misses the requested size, the result says “Target not reached.” The final original-size guard still applies.</p>
<p>Canvas re-encoding is also not an archival, metadata-preserving operation. Animated input becomes a still image, supported encoders depend on the browser, and large canvases can run into device limits. We keep error states and retained-original states distinct from successful conversions.</p>
<p>The most useful design decision was making the final byte comparison the authority. Format heuristics can improve, and encoders can change, while the downloaded result still obeys the same rule.</p>
<p>The <a href="https://github.com/WeryAIExpertTeam/browser-image-toolkit">source code is on GitHub</a>, and the <a href="https://weryaiexpertteam.github.io/browser-image-toolkit/">browser version is available here</a>.</p>
<p>I work on WeryAI, where we also build AI photo tools such as the <a href="https://www.wery.ai/en/@Zeny-Design-AI-Expert-Workspace/ai-photo-editor?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=image_toolkit">AI photo editor</a>.</p>
<p>This article was prepared with AI assistance and checked against the implementation and the project's recorded tests.</p>
]]></content:encoded></item></channel></rss>