<?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: swifttooly</title>
    <description>The latest articles on DEV Community by swifttooly (@swifttooly).</description>
    <link>https://dev.to/swifttooly</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%2F4006974%2Ff90bc3ff-1778-4e03-ae90-56b61daae394.png</url>
      <title>DEV Community: swifttooly</title>
      <link>https://dev.to/swifttooly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/swifttooly"/>
    <language>en</language>
    <item>
      <title>Compressing PDFs in the browser, and the bug that shipped empty files</title>
      <dc:creator>swifttooly</dc:creator>
      <pubDate>Sat, 08 Aug 2026 16:58:42 +0000</pubDate>
      <link>https://dev.to/swifttooly/compressing-pdfs-in-the-browser-and-the-bug-that-shipped-empty-files-19h9</link>
      <guid>https://dev.to/swifttooly/compressing-pdfs-in-the-browser-and-the-bug-that-shipped-empty-files-19h9</guid>
      <description>&lt;p&gt;Last time I wrote about &lt;a href="https://dev.to/swifttooly/compressing-images-in-the-browser-with-the-canvas-api-no-server-needed-1ga7"&gt;compressing images in the browser with the Canvas API&lt;/a&gt;. I ended that post by saying the PDF version of the story was messier. It is, and one of the bugs sat in production longer than I'd like to admit.&lt;/p&gt;

&lt;p&gt;Same constraint as before: everything runs on the user's device. No upload, no server, no API.&lt;/p&gt;

&lt;p&gt;A PDF has no quality slider&lt;/p&gt;

&lt;p&gt;With an image you call canvas.toBlob(resolve, 'image/jpeg', 0.7) and you're done. There's a dial, you turn it.&lt;/p&gt;

&lt;p&gt;A PDF is a container. Fonts, vector paths, embedded images, metadata, and a cross-reference table holding it together. No single number makes it smaller. What's making a given file heavy depends entirely on what's inside it, and two documents with the same page count can need completely different treatment.&lt;/p&gt;

&lt;p&gt;You get roughly two options.&lt;/p&gt;

&lt;p&gt;Rebuild the file structure. Load it with pdf-lib and write it back out:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
import { PDFDocument } from 'pdf-lib';&lt;/p&gt;

&lt;p&gt;const pdf = await PDFDocument.load(bytes);&lt;br&gt;
const out = await pdf.save({ useObjectStreams: true });&lt;/p&gt;

&lt;p&gt;This is lossless. Text stays selectable, fonts stay fonts. The savings are modest and sometimes zero, because all you're doing is tidying up structure.&lt;/p&gt;

&lt;p&gt;Re-render every page. Draw each page onto a canvas with pdf.js, re-encode it as JPEG, then assemble a new PDF from those images. This is where the impressive percentages come from, and it costs you a lot: text becomes pixels, selection stops working, screen readers get nothing, and small type goes fuzzy at low quality.&lt;/p&gt;

&lt;p&gt;Most of the online compressors advertising "up to 90% smaller" are quietly doing the second one.&lt;/p&gt;

&lt;p&gt;The bug: pdf.js empties your buffer&lt;/p&gt;

&lt;p&gt;This is the one that cost me real debugging time.&lt;/p&gt;

&lt;p&gt;Here's the shape of the code that broke:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const buf = await file.arrayBuffer();&lt;/p&gt;

&lt;p&gt;const doc = await pdfjsLib.getDocument({ data: buf }).promise;&lt;br&gt;
const compressed = await compress(doc);&lt;/p&gt;

&lt;p&gt;if (compressed.size &amp;gt;= file.size) {&lt;br&gt;
  // Can't beat the original, so hand it back untouched&lt;br&gt;
  return new Blob([buf], { type: 'application/pdf' });&lt;br&gt;
}&lt;br&gt;
return compressed;&lt;/p&gt;

&lt;p&gt;Looks fine. It isn't.&lt;/p&gt;

&lt;p&gt;getDocument({ data }) transfers the ArrayBuffer to its worker. Transferred, not copied. The moment that happens your buf is detached and its byteLength is 0. No error, no warning, just an empty buffer sitting where your file used to be.&lt;/p&gt;

&lt;p&gt;So the fallback branch, the one that runs when a PDF is already well optimized and can't be shrunk, handed people a 0-byte download. And it only ever fired for files that couldn't be compressed, which is precisely the case I never thought to test.&lt;/p&gt;

&lt;p&gt;The fix is one line:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
if (compressed.size &amp;gt;= file.size) {&lt;br&gt;
  return file;  // File already extends Blob&lt;br&gt;
}&lt;/p&gt;

&lt;p&gt;You never needed to reconstruct anything.&lt;/p&gt;

&lt;p&gt;If you genuinely need the bytes after loading, copy before you hand them over:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
const buf = await file.arrayBuffer();&lt;br&gt;
const doc = await pdfjsLib.getDocument({ data: buf.slice(0) }).promise;&lt;/p&gt;

&lt;p&gt;Two things worth knowing. getDocument also takes a plain Uint8Array, and the same detach rule applies there. And this isn't a pdf.js bug. Transferables are how you move data into a worker without copying it, and losing access on the sending side is the entire point. It's documented behaviour. I just didn't read closely enough.&lt;/p&gt;

&lt;p&gt;Never hand back a bigger file&lt;/p&gt;

&lt;p&gt;Same rule as the image post, and it matters more here:&lt;/p&gt;

&lt;p&gt;js&lt;br&gt;
if (out.byteLength &amp;gt;= file.size) return file;&lt;/p&gt;

&lt;p&gt;PDFs produced by decent tooling are frequently well compressed already, and re-saving one can add bytes rather than remove them. A compressor that returns a larger file while reporting success is worse than one that does nothing at all.&lt;/p&gt;

&lt;p&gt;Memory will end you&lt;/p&gt;

&lt;p&gt;Rendering every page to canvas at full resolution kills a mobile tab. A 40-page document at 2x scale is an enormous number of pixels to hold at once, and the tab usually dies silently.&lt;/p&gt;

&lt;p&gt;Render one page, encode it, throw the canvas away, then move to the next. Don't collect canvases in an array and process them at the end. I also cap the render scale instead of letting page dimensions decide it for me.&lt;/p&gt;

&lt;p&gt;What I'd tell someone starting this&lt;/p&gt;

&lt;p&gt;Try the lossless path first and measure whether it was enough. Only rasterize when the user asks for it and understands what they're trading away.&lt;/p&gt;

&lt;p&gt;And test the path where compression fails. That's where my bug lived, and it's the branch nobody writes a test for.&lt;/p&gt;

&lt;p&gt;The tool is at &lt;a href="https://swifttooly.com/pdf-compressor" rel="noopener noreferrer"&gt;SwiftTooly&lt;/a&gt; if you want to try it. Free, and the file never leaves your machine.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Compressing images in the browser with the Canvas API (no server needed)</title>
      <dc:creator>swifttooly</dc:creator>
      <pubDate>Sun, 12 Jul 2026 19:48:12 +0000</pubDate>
      <link>https://dev.to/swifttooly/compressing-images-in-the-browser-with-the-canvas-api-no-server-needed-1ga7</link>
      <guid>https://dev.to/swifttooly/compressing-images-in-the-browser-with-the-canvas-api-no-server-needed-1ga7</guid>
      <description>&lt;p&gt;A while ago I wrote about &lt;a href="https://dev.to/swifttooly/why-i-built-50-browser-based-tools-with-zero-backend-3cg1"&gt;why I built 50+ browser-based tools with zero backend&lt;/a&gt;. This time I want to go deeper into one specific tool: image compression that runs entirely in the browser. No upload, no server, no API — just the Canvas API doing all the work on the user's device.&lt;/p&gt;

&lt;p&gt;Here's how it works, plus a couple of gotchas that cost me real debugging time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea
&lt;/h2&gt;

&lt;p&gt;Image compression in the browser is surprisingly simple in principle:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the user's file with the File API&lt;/li&gt;
&lt;li&gt;Draw it onto a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Re-export the canvas as a Blob at a lower quality&lt;/li&gt;
&lt;li&gt;Trigger a download&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. The user's photo never leaves their machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code
&lt;/h2&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;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;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Load the file into an Image element&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&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;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&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;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Draw it onto a canvas&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;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&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;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalHeight&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;img&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="c1"&gt;// 3. Re-export as a compressed Blob&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;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;revokeObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&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;blob&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;Then downloading is just:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;downloadBlob&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="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="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="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="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="nf"&gt;click&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;revokeObjectURL&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="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;
  
  
  Gotcha #1: transparent PNGs turn black
&lt;/h2&gt;

&lt;p&gt;This one bit me in production. If the input is a PNG with transparency and you export it as JPEG, the transparent areas become solid black — JPEG has no alpha channel.&lt;/p&gt;

&lt;p&gt;Two ways to handle it:&lt;/p&gt;

&lt;p&gt;Option A — fill a white background first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fillStyle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#ffffff&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;fillRect&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;canvas&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="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;img&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Option B — detect transparency and export WebP instead (WebP supports alpha and compresses well):&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;outputType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasTransparency&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&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="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;outputType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went with Option B — users who upload a transparent logo expect to get a transparent result back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #2: compression can make files BIGGER
&lt;/h2&gt;

&lt;p&gt;If the input is already heavily optimized (or tiny), re-encoding it can produce a larger file. Always compare sizes and never return a worse result:&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;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;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="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep the original&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds obvious, but a lot of online compressors skip this check and happily hand you a "compressed" file that's bigger than what you started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha #3: huge images can crash the tab
&lt;/h2&gt;

&lt;p&gt;A 50-megapixel photo drawn onto a canvas can eat enough memory to kill the tab on mobile — silently, with no error. Add a size guard before decoding and show a friendly message instead of letting the browser die.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother doing this client-side?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Privacy: the user's photos never touch a server. Nothing to store, nothing to leak.&lt;/li&gt;
&lt;li&gt;Cost: zero bandwidth and zero server bills, no matter how many people use it.&lt;/li&gt;
&lt;li&gt;Speed: no upload/download round-trip — compression starts instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The limits are real too: no server means no ML-based smart compression, and very large batches are constrained by device memory. For everyday "shrink this photo before emailing it" use cases, though, the browser is more than enough.&lt;/p&gt;

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

&lt;p&gt;I use exactly this approach (plus resize-before-compress for extra savings) in the &lt;a href="https://swifttooly.com/image-compressor" rel="noopener noreferrer"&gt;Image Compressor on SwiftTooly&lt;/a&gt; — free, no sign-up, and your files stay on your device.&lt;/p&gt;

&lt;p&gt;Questions about the implementation? Happy to share more details in the comments — the PDF compression story is even messier, and I might write that one up next.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why I built 50+ browser-based tools with zero backend</title>
      <dc:creator>swifttooly</dc:creator>
      <pubDate>Sun, 28 Jun 2026 20:35:00 +0000</pubDate>
      <link>https://dev.to/swifttooly/why-i-built-50-browser-based-tools-with-zero-backend-3cg1</link>
      <guid>https://dev.to/swifttooly/why-i-built-50-browser-based-tools-with-zero-backend-3cg1</guid>
      <description>&lt;p&gt;For years, every time I needed to merge a PDF or compress an image online, I had to upload my private files to some random server and just hope they got deleted. That always felt wrong.&lt;/p&gt;

&lt;p&gt;So I started building small tools that do everything in the browser — no upload, no backend, no database. The files never leave the user's device. I ended up with 50+ of them, and I want to share what I learned about going fully client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client-side?
&lt;/h2&gt;

&lt;p&gt;Three big wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Privacy: the user's files never touch a server. Nothing to leak, nothing to store, nothing to comply with.&lt;/li&gt;
&lt;li&gt;Cost: no server means no hosting bill that scales with traffic. A static site can handle millions of users.&lt;/li&gt;
&lt;li&gt;Speed: no upload/download round-trip. The work happens instantly on the user's machine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The browser can do more than people think
&lt;/h2&gt;

&lt;p&gt;Most "simple" tools don't actually need a server. Modern browser APIs cover a huge amount:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Canvas API — resize, crop, compress, and convert images entirely in memory.&lt;/li&gt;
&lt;li&gt;File API + Blob — read user files and trigger downloads without ever uploading.&lt;/li&gt;
&lt;li&gt;Web Crypto API — hashing (SHA-256, etc.) natively, no library needed.&lt;/li&gt;
&lt;li&gt;Libraries like pdf-lib and pdf.js — full PDF manipulation (merge, split, rotate) on the client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example, compressing an image is just: read the file → draw it to a canvas → re-export at a lower quality → download the blob. No server in the loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does NOT work client-side
&lt;/h2&gt;

&lt;p&gt;Being honest about the limits, because this matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Downloading videos from YouTube/TikTok — blocked by CORS, needs a server.&lt;/li&gt;
&lt;li&gt;Anything needing live data (real-time currency rates, etc.) — needs an API.&lt;/li&gt;
&lt;li&gt;Heavy video transcoding — technically possible with WASM but painful and slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Knowing what &lt;em&gt;can't&lt;/em&gt; be done client-side saved me from shipping broken tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;I put all of this into &lt;a href="https://swifttooly.com" rel="noopener noreferrer"&gt;[SwiftTooly]&lt;/a&gt; — a free collection of browser-based tools (PDF, image, text, QR, converters, dev utilities). Everything runs locally, no sign-up.&lt;/p&gt;

&lt;p&gt;Happy to answer questions about any of the implementations — the image and PDF stuff especially has some fun edge cases.&lt;/p&gt;

&lt;p&gt;What other tools do you think genuinely don't need a backend?&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>showdev</category>
      <category>sideprojects</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
