<?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: hieu.dev</title>
    <description>The latest articles on DEV Community by hieu.dev (@hieu_dev).</description>
    <link>https://dev.to/hieu_dev</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%2F3891618%2F485fe32f-89f8-4e5d-819e-e8ed9f6472cf.JPG</url>
      <title>DEV Community: hieu.dev</title>
      <link>https://dev.to/hieu_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hieu_dev"/>
    <language>en</language>
    <item>
      <title>How I Built a Browser-Based Image Compressor with Next.js (No Server Required)</title>
      <dc:creator>hieu.dev</dc:creator>
      <pubDate>Wed, 13 May 2026 16:43:35 +0000</pubDate>
      <link>https://dev.to/hieu_dev/how-i-built-a-browser-based-image-compressor-with-nextjs-no-server-required-5fnj</link>
      <guid>https://dev.to/hieu_dev/how-i-built-a-browser-based-image-compressor-with-nextjs-no-server-required-5fnj</guid>
      <description>&lt;p&gt;I wanted to build a free image compressor — no uploads to a server, no account required, just drop an image and get a compressed file back instantly. Here's what I learned building &lt;a href="https://compressimg.pro" rel="noopener noreferrer"&gt;compressimg.pro&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why browser-based?
&lt;/h2&gt;

&lt;p&gt;Most image compression tools send your file to a server, compress it there, and send it back. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your image leaves your device (privacy concern)&lt;/li&gt;
&lt;li&gt;You need a backend to maintain&lt;/li&gt;
&lt;li&gt;Latency on every compression&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The browser can handle this entirely client-side using the Canvas API and Web Workers. No server, no cost, no privacy issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 14&lt;/strong&gt; with &lt;code&gt;output: 'export'&lt;/code&gt; (static site — deployed free on Vercel)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;browser-image-compression&lt;/strong&gt; — handles the heavy lifting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; throughout&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The static export is key. Since there's no server-side logic, the entire site builds to static HTML/JS and deploys in seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;compress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trailingSlash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;h2&gt;
  
  
  The compression logic
&lt;/h2&gt;

&lt;p&gt;The core is surprisingly simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/compress.ts&lt;/span&gt;
&lt;span class="k"&gt;export&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;compressImage&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="nx"&gt;File&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CompressOptions&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CompressResult&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Dynamic import — keeps initial bundle small&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageCompression&lt;/span&gt; &lt;span class="p"&gt;}&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;browser-image-compression&lt;/span&gt;&lt;span class="dl"&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;compressed&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;imageCompression&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;maxWidthOrHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;maxDimensionPx&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;useWebWorker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;// keeps main thread unblocked&lt;/span&gt;
    &lt;span class="na"&gt;initialQuality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&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="na"&gt;fileType&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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alwaysKeepResolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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="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;compressed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;originalSize&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="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;compressedSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;compressed&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="na"&gt;previewUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;compressed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;format&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="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&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="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="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 things worth noting:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;useWebWorker: true&lt;/code&gt;&lt;/strong&gt; — This is the most important flag. Without it, compression runs on the main thread and freezes the UI for 1–3 seconds on large files. With it, the browser spawns a worker thread and the UI stays responsive throughout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic import&lt;/strong&gt; — &lt;code&gt;browser-image-compression&lt;/code&gt; is ~50KB gzipped. Importing it at the top of the file adds it to the initial bundle and hurts LCP. Importing it inside the handler means it only loads when the user actually selects a file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload UX — state machine approach
&lt;/h2&gt;

&lt;p&gt;The upload box has 5 states: &lt;code&gt;idle&lt;/code&gt;, &lt;code&gt;dragging&lt;/code&gt;, &lt;code&gt;processing&lt;/code&gt;, &lt;code&gt;done&lt;/code&gt;, &lt;code&gt;error&lt;/code&gt;. Defining these upfront made the component much cleaner than the typical boolean-flag soup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;UploadState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dragging&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;processing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The component handles three input methods — click, drag &amp;amp; drop, and &lt;code&gt;Ctrl+V&lt;/code&gt; paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handlePaste&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ClipboardEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;HTMLDivElement&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clipboardData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&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;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&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/&lt;/span&gt;&lt;span class="dl"&gt;'&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAsFile&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;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;validateAndSelect&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="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;validateAndSelect&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Paste support is underrated — power users (designers, developers) paste screenshots constantly. Adding it took 10 lines and made the tool noticeably more useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance mistake that cost me LCP 7.1s → 2.0s
&lt;/h2&gt;

&lt;p&gt;My first deploy had LCP of 7.1s on mobile. After running Lighthouse, the culprit was Google Analytics and AdSense loaded with &lt;code&gt;strategy="afterInteractive"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Switching both to &lt;code&gt;strategy="lazyOnload"&lt;/code&gt; dropped LCP to 2.0s instantly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before — blocks LCP&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://www.googletagmanager.com/gtag/js"&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"afterInteractive"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// After — deferred until idle&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://www.googletagmanager.com/gtag/js"&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lazyOnload"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson: any third-party script with &lt;code&gt;afterInteractive&lt;/code&gt; runs as soon as the page is interactive — which is right when the browser is trying to paint your LCP element. &lt;code&gt;lazyOnload&lt;/code&gt; waits until the browser is genuinely idle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blob URL memory management
&lt;/h2&gt;

&lt;p&gt;One thing that bites people: &lt;code&gt;URL.createObjectURL()&lt;/code&gt; creates a reference that persists in memory until you explicitly release it. After the user downloads their file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;triggerDownload&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;Blob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&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;a&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;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// Release after 1s to ensure download started&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&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;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1000&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;Without &lt;code&gt;revokeObjectURL&lt;/code&gt;, compressing many files in one session leaks memory progressively.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HEIC support is tricky.&lt;/strong&gt; iOS devices often produce &lt;code&gt;.heic&lt;/code&gt; files that &lt;code&gt;browser-image-compression&lt;/code&gt; can't handle natively. I ended up adding a separate HEIC-to-JPEG conversion step before compression, which added complexity I didn't expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target file size is hard.&lt;/strong&gt; Users often want "compress to under 100KB" rather than "compress at quality 80." Hitting a target size requires binary search over the quality parameter — doable, but not something the library handles out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The full tool is live at &lt;a href="https://compressimg.pro" rel="noopener noreferrer"&gt;compressimg.pro&lt;/a&gt; — free, no account, no upload limits. Supports JPG, PNG, WebP, HEIC, and GIF.&lt;/p&gt;

&lt;p&gt;If you're building something similar, the &lt;code&gt;browser-image-compression&lt;/code&gt; library does 90% of the hard work. The remaining 10% is UX — handling all the edge cases around file validation, state management, and performance.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>performance</category>
    </item>
    <item>
      <title>How I built a privacy-first image compressor that runs entirely in the browser</title>
      <dc:creator>hieu.dev</dc:creator>
      <pubDate>Tue, 28 Apr 2026 05:45:33 +0000</pubDate>
      <link>https://dev.to/hieu_dev/how-i-built-a-privacy-first-image-compressor-that-runs-entirely-in-the-browser-3b8l</link>
      <guid>https://dev.to/hieu_dev/how-i-built-a-privacy-first-image-compressor-that-runs-entirely-in-the-browser-3b8l</guid>
      <description>&lt;p&gt;Most online image compressors have one thing in common: your files go to their server. You upload a private photo, it travels across the internet, gets processed on someone else's machine, and comes back. That always felt wrong to me.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://compressimg.pro/compress-image" rel="noopener noreferrer"&gt;CompressImg&lt;/a&gt; — an image compressor that does everything in your browser. Your files never leave your device.&lt;/p&gt;

&lt;p&gt;Here's how it works under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core: browser-image-compression + Web Worker
&lt;/h2&gt;

&lt;p&gt;The heavy lifting is done by the &lt;code&gt;browser-image-compression&lt;/code&gt; library, but the key is &lt;em&gt;where&lt;/em&gt; it runs:&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="c1"&gt;// compress.ts&lt;/span&gt;
&lt;span class="k"&gt;export&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;compressImage&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="nx"&gt;options&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;imageCompression&lt;/span&gt; &lt;span class="p"&gt;}&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;browser-image-compression&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;imageCompression&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;maxSizeMB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;// don't limit by size, use quality instead&lt;/span&gt;
    &lt;span class="na"&gt;initialQuality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;options&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="na"&gt;alwaysKeepResolution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// never upscale&lt;/span&gt;
    &lt;span class="na"&gt;maxWidthOrHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1920&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// cap at screen resolution&lt;/span&gt;
    &lt;span class="na"&gt;useWebWorker&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;// off main thread&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 things matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic import&lt;/strong&gt; — &lt;code&gt;browser-image-compression&lt;/code&gt; is only loaded when the user actually compresses an image. It's not in the initial bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;useWebWorker: true&lt;/code&gt;&lt;/strong&gt; — compression runs off the main thread. The UI stays responsive even for large files.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 14&lt;/strong&gt; with &lt;code&gt;output: 'export'&lt;/code&gt; — generates a pure static site. No server, no API routes, nothing to scale.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt; — serves the static files from a global CDN. Handles any amount of concurrent users automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; — DNS only (gray cloud). Vercel manages SSL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The PageSpeed problem
&lt;/h2&gt;

&lt;p&gt;My first PageSpeed score on mobile was LCP 7.1s. Way off the &amp;lt; 2.5s target.&lt;/p&gt;

&lt;p&gt;The culprit turned out to be three things stacking up:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Font loading&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subsets&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;latin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;swap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Inter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;subsets&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;latin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;optional&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;display: swap&lt;/code&gt;, the browser renders text with a fallback font, then swaps to Inter when it loads. On mobile simulation (slow 4G), Inter was loading at ~5s — and LCP was measured at the swap moment.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;display: optional&lt;/code&gt;, if the font isn't immediately available, the browser uses the system font and never swaps. LCP is measured right away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Third-party scripts competing for bandwidth&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AdSense (231 KiB) and GA4 (140 KiB) were loading with &lt;code&gt;afterInteractive&lt;/code&gt;, which fires right after hydration. On mobile, this competed with the LCP element for bandwidth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="nx"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;afterInteractive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="nx"&gt;strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;lazyOnload&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;lazyOnload&lt;/code&gt; defers until the browser is truly idle — after LCP has already painted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. ContentSection as a client component&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I had added &lt;code&gt;'use client'&lt;/code&gt; to the ContentSection (1000+ words of SEO text) to handle the FAQ accordion. This caused all that static text to be bundled as JavaScript instead of static HTML.&lt;/p&gt;

&lt;p&gt;Fix: extract the interactive part (accordion toggle) into its own tiny client component, let the rest be server-rendered HTML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// FAQItem.tsx — only this needs 'use client'&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FAQItem&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;answer&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&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;After all three fixes: &lt;strong&gt;LCP dropped from 7.1s to 2.0s&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy by design
&lt;/h2&gt;

&lt;p&gt;The architecture makes privacy the default, not a feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static export = no server to log requests&lt;/li&gt;
&lt;li&gt;Compression via Canvas API = no data transmission&lt;/li&gt;
&lt;li&gt;No cookies, no session storage, no user tracking beyond GA4 page views&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ads.txt&lt;/code&gt; published for AdSense transparency&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;The tool is live at &lt;a href="https://compressimg.pro/compress-image" rel="noopener noreferrer"&gt;compressimg.pro&lt;/a&gt;. Next steps are adding more tools (resize, format conversion) once the SEO for image compression establishes a baseline.&lt;/p&gt;

&lt;p&gt;If you're building something similar, the main lesson: &lt;strong&gt;measure PageSpeed early and often&lt;/strong&gt;. The LCP issue would have been invisible without it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Next.js, deployed on Vercel. Questions welcome in the comments.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;It's been about 3 weeks since I published this post. Here's what I learned.&lt;/p&gt;

&lt;h3&gt;
  
  
  The formats people actually need
&lt;/h3&gt;

&lt;p&gt;I thought JPG and PNG would cover 95% of use cases. I was wrong.&lt;/p&gt;

&lt;p&gt;The first thing users asked about was &lt;strong&gt;HEIC&lt;/strong&gt; — iPhone photos that Windows &lt;br&gt;
can't open natively. Added &lt;a href="https://compressimg.pro/heic-to-jpg/" rel="noopener noreferrer"&gt;HEIC to JPG conversion&lt;/a&gt; &lt;br&gt;
using &lt;code&gt;heic2any&lt;/code&gt; with a dynamic import so it doesn't bloat the main bundle:&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
ts
const heic2any = await import('heic2any')
const blob = await heic2any.default({ blob: file, toType: 'image/jpeg' })
Then came GIF. Animated GIF compression is a completely different problem —
you can't just run it through Canvas. I ended up with gifuct-js to decode
frames and gif.js with a Web Worker to re-encode. The worker file has to
live in /public/gif.worker.js — took me an embarrassing amount of time to
figure that out.

Also added TIFF (via utif), AVIF (native Canvas in Chrome), and BMP.

The SEO side
The compression logic was the easy part. The harder problem: how do you get
Google to care about a new free tool?

I ended up building 72 static pages with Next.js output: 'export' — one
for each format, platform, and use case. Pages like
compress image to 100KB
target very specific long-tail queries ("compress photo for government form",
"image under 100kb for email attachment") that have real search volume and
almost no competition from the big tools.

Three weeks in: 534 impressions on Google Search Console, pages ranking
between position 8–15 for terms like "does Microsoft Teams compress images"
and "compress image for LinkedIn". No clicks yet — that comes when you break
into the top 5. But the trajectory is there.

The key technical decision that made this work: every page is fully static
(SSG, no SSR), with its own layout.tsx containing JSON-LD structured data
(WebApplication + FAQPage schema). Google indexes these instantly compared
to client-rendered pages.

What's next
The tool is live at compressimg.pro — still
100% free, no account, no uploads to any server.

If you're building something similar, the biggest lesson: pick client-side
processing from day one. Not just for privacy — it makes the whole
architecture simpler. No server to maintain, no storage costs, no GDPR
headaches, free hosting on Vercel forever.

Happy to answer questions about any of the implementation details below.


---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
