<?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[Rishab Dugar]]></title><description><![CDATA[Notes from building Fizzdoc and other things: browser engineering, WebAssembly, privacy-first web apps, agentic AI, system design and honest numbers from real s]]></description><link>https://rishabdugarjain.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Nov 2026 09:41:19 GMT</lastBuildDate><atom:link href="https://rishabdugarjain.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I don't run analytics on my site. Here's what 273,000 requests told me anyway.]]></title><description><![CDATA[No analytics script, so I used Cloudflare's numbers to find how many visitors are real: robots.txt, Singapore bots, wp-config probes, model downloads.]]></description><link>https://rishabdugarjain.hashnode.dev/i-don-t-run-analytics-on-my-site-here-s-what-273-000-requests-told-me-anyway</link><guid isPermaLink="true">https://rishabdugarjain.hashnode.dev/i-don-t-run-analytics-on-my-site-here-s-what-273-000-requests-told-me-anyway</guid><category><![CDATA[webdev]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[analytics]]></category><category><![CDATA[privacy]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Rishab Dugar Jain]]></dc:creator><pubDate>Sat, 07 Nov 2026 13:56:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/3c75138e-2ebe-4723-a848-1e037c4a3c59.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Fizzdoc, my free PDF and photo toolkit, has no analytics script. No Google Analytics, no pixel, no cookie banner. The page isn't even allowed to make outside requests: its Content Security Policy blocks every connection that doesn't go back to <a href="http://fizzdoc.com">fizzdoc.com</a>.</p>
<p>That was the point. A tool that promises "your files never leave your device" shouldn't be quietly reporting on you either.</p>
<p>It also means I had no idea who uses it.</p>
<p>The only numbers I have are the ones every host sees anyway: Cloudflare's request logs, summed per day, with no cookies and no fingerprinting. So I took 30 days of them and tried to answer one question: <strong>how many of my "visitors" are actually people?</strong></p>
<p>Here's what the dashboard says:</p>
<table>
<thead>
<tr>
<th>Last 30 days</th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td>273,490</td>
</tr>
<tr>
<td>Unique visitors</td>
<td>17,490</td>
</tr>
<tr>
<td>Visits</td>
<td>36,010</td>
</tr>
<tr>
<td>Page views</td>
<td>51,880</td>
</tr>
<tr>
<td>Data served</td>
<td>18.23 GB</td>
</tr>
<tr>
<td>Server errors (5xx)</td>
<td>0</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/6fec464e-75bf-4c14-aac9-e96d3c4baa89.png" alt="30 days of requests on fizzdoc.com" style="display:block;margin:0 auto" />

<p>36,000 visits for a side project sounds amazing. It isn't true. Let's take it apart.</p>
<h2>Clue 1: the second most popular file isn't a page</h2>
<p>Sort requests by path and the top of the list is:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Requests</th>
</tr>
</thead>
<tbody><tr>
<td><code>/</code> (home page)</td>
<td>7,690</td>
</tr>
<tr>
<td><code>/robots.txt</code></td>
<td>2,990</td>
</tr>
<tr>
<td><code>/sw.js</code></td>
<td>2,750</td>
</tr>
<tr>
<td><code>/theme.js</code></td>
<td>1,990</td>
</tr>
<tr>
<td><code>/manifest.webmanifest</code></td>
<td>1,660</td>
</tr>
</tbody></table>
<p><code>robots.txt</code> is the second most requested file on the site. No human opens it on purpose. That's 3,000 requests from crawlers asking whether they're allowed in. (They are. I welcome search engines and AI answer engines, because that's how people find a free tool.)</p>
<h2>Clue 2: the "browsers" aren't browsers</h2>
<p>Cloudflare groups page views by browser. The biggest group isn't Chrome:</p>
<table>
<thead>
<tr>
<th>Browser</th>
<th>Page views</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Unknown / Others</strong></td>
<td>30,940</td>
</tr>
<tr>
<td>Chrome</td>
<td>10,130</td>
</tr>
<tr>
<td>Mobile Safari</td>
<td>3,780</td>
</tr>
<tr>
<td>Googlebot</td>
<td>1,890</td>
</tr>
<tr>
<td>Bingbot</td>
<td>1,090</td>
</tr>
<tr>
<td>Edge</td>
<td>970</td>
</tr>
<tr>
<td>Firefox</td>
<td>630</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/a79319f9-cc9d-4655-89d3-ec78fe6fa134.png" alt="The top two &quot;visitors&quot; are crawlers" style="display:block;margin:0 auto" />

<p>"Unknown" is three times bigger than Chrome. The user-agent list explains it:</p>
<ul>
<li><strong>Petalbot</strong> (Huawei's search crawler): 10,290 page views. My single biggest "visitor".</li>
<li><strong>SemrushBot</strong> (an SEO tool): 5,150.</li>
<li>An <strong>iPhone on iOS 13</strong>, from 2019, with 2,760 page views. That exact user agent is a favourite of scrapers.</li>
<li><code>msh-pdfmin/1</code>: 2,720. I have no idea what this is. It reads PDFs, apparently.</li>
<li>ElevenLabs' crawler, Amazonbot, Googlebot and Bingbot after that.</li>
</ul>
<p>Across all requests, <strong>11,790 had no user agent at all</strong>, and 14,700 came from <strong>Headless Chrome</strong>: Chrome with no screen, which is what scrapers and test bots run.</p>
<h2>Clue 3: Singapore loves my site (it doesn't)</h2>
<p>Page views by country:</p>
<table>
<thead>
<tr>
<th>Country</th>
<th>Page views</th>
</tr>
</thead>
<tbody><tr>
<td>United States</td>
<td>22,120</td>
</tr>
<tr>
<td><strong>Singapore</strong></td>
<td>12,240</td>
</tr>
<tr>
<td>Germany</td>
<td>2,120</td>
</tr>
<tr>
<td>Brazil</td>
<td>1,730</td>
</tr>
<tr>
<td><strong>India</strong></td>
<td>1,240</td>
</tr>
<tr>
<td>France</td>
<td>1,150</td>
</tr>
<tr>
<td>Finland</td>
<td>1,120</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/b105a74f-9439-4bf3-9111-d09968c89dbf.png" alt="Page views by country" style="display:block;margin:0 auto" />

<p>Singapore, with six million people, sends <strong>ten times more page views than India</strong>, where I live and where most people I know use the site.</p>
<p>But the best clue is the ratio of requests to page views, because a real visit pulls in fonts, scripts and, if you use a tool, its engine:</p>
<table>
<thead>
<tr>
<th>Country</th>
<th>Requests</th>
<th>Page views</th>
<th>Requests per page</th>
</tr>
</thead>
<tbody><tr>
<td>India</td>
<td>27,400</td>
<td>1,240</td>
<td><strong>22</strong></td>
</tr>
<tr>
<td>United States</td>
<td>73,110</td>
<td>22,120</td>
<td>3.3</td>
</tr>
<tr>
<td>Singapore</td>
<td>18,910</td>
<td>12,240</td>
<td><strong>1.5</strong></td>
</tr>
</tbody></table>
<p>Indian visitors load 22 files per page: they're opening tools and using them. "Singapore" loads one and a half: fetch the HTML, leave. That's what a crawler in a data centre does, and Singapore is full of data centres.</p>
<h2>Clue 4: bots looking for a server that doesn't exist</h2>
<p>Fizzdoc has no backend. No database, no login, no API, no WordPress. Bots don't know that, so they keep checking:</p>
<table>
<thead>
<tr>
<th>Path</th>
<th>Blocked requests</th>
</tr>
</thead>
<tbody><tr>
<td><code>/api/</code></td>
<td>60</td>
</tr>
<tr>
<td><code>/wp-config.php</code></td>
<td>40</td>
</tr>
<tr>
<td><code>/wp-config.php.old</code></td>
<td>40</td>
</tr>
<tr>
<td><code>/settings</code></td>
<td>40</td>
</tr>
<tr>
<td><code>/login</code></td>
<td>20</td>
</tr>
<tr>
<td><code>/wp-config.php.bak</code></td>
<td>20</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/d60926f3-46a5-4720-b2f2-1cf8fd43bb59.png" alt="Bots looking for WordPress passwords" style="display:block;margin:0 auto" />

<p><code>wp-config.php</code> is where WordPress keeps its database password, and <code>.old</code> and <code>.bak</code> are the backup copies people forget to delete. Someone is checking my static site for all three.</p>
<p>Add the rest of the guessing and <strong>30,650 requests got a 4xx error</strong>: one in nine requests was for something that never existed.</p>
<p>My favourite part of having no backend: none of this matters. There's nothing to find.</p>
<h2>So how many people? Count the downloads, not the visits</h2>
<p>Page views are easy to fake. Downloading an AI model is not, and bots don't bother, because they never run the tool.</p>
<p>Fizzdoc's AI tools download a model the first time you use them, and the browser keeps it afterwards. So the bandwidth spent on model files is a rough count of <strong>real people who actually used an AI tool</strong>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/da2e03c7-86e7-49f2-aefc-63a7818334bd.png" alt="Where the 18 GB went: model files, not pages" style="display:block;margin:0 auto" />

<ul>
<li><strong>Phone background removal.</strong> The model is 4.57 MB and served 1.19 GB: about <strong>260 downloads</strong>.</li>
<li><strong>Desktop background removal.</strong> The model is about 100 MB, split into 20 MB parts because Cloudflare Pages won't host a file over 25 MB. Each part served 1 to 1.7 GB: roughly <strong>50 to 80 full downloads</strong>.</li>
<li><strong>Speech to text.</strong> The encoder is 10 MB and served 608 MB: about <strong>60 transcriptions</strong>.</li>
</ul>
<p>These are rough. A download that stops halfway still counts, and one person on two devices counts twice. But the scale is clear: tens of thousands of "visits" in the dashboard, <strong>a few hundred real uses of the heavy tools</strong>. Add the people using light tools that need no model (compress, merge, convert) and the real number of people is in the low thousands, close to the 17,490 "unique visitors" at best, and nowhere near 36,000 visits.</p>
<p>Honestly? A few thousand people a month trusting a side project with their files is more than I expected. I just wanted the real number.</p>
<h2>Bonus: my CDN wasn't caching my heaviest files</h2>
<p>One more number bothered me: <strong>only 41.65% of requests came from Cloudflare's cache, and only 11.82% of the bandwidth</strong> (2.15 GB of 18.23 GB).</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/e75c5eac-20c5-4d71-b306-0ed0db15530d.png" alt="Cache hit rate" style="display:block;margin:0 auto" />

<p>For a site that is 100% static files, that's low. The headers explain it:</p>
<pre><code class="language-plaintext">$ curl -sI https://fizzdoc.com/assets/index-....js
cache-control: public, max-age=31536000, immutable      &lt;- cached for a year

$ curl -sI https://fizzdoc.com/bg/birefnet-lite/model.onnx.part0
cache-control: public, max-age=0, must-revalidate       &lt;- re-checked every time
cf-cache-status: DYNAMIC
</code></pre>
<p>The JavaScript is cached perfectly. Its filename contains a hash of its contents, so it can be cached "forever". But the model files, the heaviest files on the site, had no cache rule, so 16 GB went out uncached.</p>
<p>Visitors barely noticed, because the browser keeps a model after the first download. But it's the wrong default, and it's a few lines in a <code>_headers</code> file. That's the next post: the fix, and the before and after.</p>
<h2>What I learned</h2>
<ol>
<li><strong>Dashboard visits are not people.</strong> On a small site, bots can easily be most of the traffic. Don't put "36k visits" on a slide.</li>
<li><strong>Your top paths are a free bot report.</strong> <code>robots.txt</code>, <code>wp-config.php</code> and 404s tell you who's knocking.</li>
<li><strong>Requests per page view is a great bot detector.</strong> People load assets; crawlers fetch HTML and leave.</li>
<li><strong>Count something bots can't fake.</strong> For me it's model downloads. For you it might be a form submit, an export or a signup.</li>
<li><strong>You don't need tracking to learn a lot.</strong> Aggregate server-side numbers answered most of my questions without a single cookie.</li>
<li><strong>Check your cache headers.</strong> A static site with a 42% cache hit rate has a config bug, not a traffic problem.</li>
</ol>
<p>Fizzdoc is free and open source. It runs 74 PDF, photo and audio tools entirely in your browser, in 17 languages.</p>
<ul>
<li>Try it: <a href="https://fizzdoc.com">fizzdoc.com</a></li>
<li>Code: <a href="https://github.com/kingrishabdugar/fizzdoc">github.com/kingrishabdugar/fizzdoc</a></li>
</ul>
<p>If you've been through your own logs and found something weird, tell me in the comments. And if you think my real-human estimate is too high or too low, I'd love to know how you'd measure it.</p>
]]></content:encoded></item><item><title><![CDATA[The architecture of a website that can't see your files (74 tools, no backend)]]></title><description><![CDATA[How Fizzdoc runs 74 file tools with no backend: a CSP that blocks uploads, prebuilt pages, one worker per job, split AI models and offline support.]]></description><link>https://rishabdugarjain.hashnode.dev/the-architecture-of-a-website-that-can-t-see-your-files-74-tools-no-backend</link><guid isPermaLink="true">https://rishabdugarjain.hashnode.dev/the-architecture-of-a-website-that-can-t-see-your-files-74-tools-no-backend</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[System Design]]></category><category><![CDATA[architecture]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Rishab Dugar Jain]]></dc:creator><pubDate>Sat, 07 Nov 2026 13:47:26 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/1f107fd8-f840-4576-a4f1-341726b42b17.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/319bto3g9dfv5s24arrw.png" alt="Fizzdoc Home Edit &amp; Create" style="display:block;margin:0 auto" />

<p>Most "free online PDF tool" sites have the same architecture: your file goes up to their server, gets processed, and comes back. The privacy policy says they delete it after an hour. You have to take their word for it.</p>
<p>I learned this as the family's unofficial IT person. Every few weeks, someone sends me an Aadhaar scan or a bank statement and asks me to "make it smaller" or "unlock it". I kept uploading other people's documents to sites I knew nothing about, and at some point that stopped feeling okay.</p>
<p>After my first two posts on Fizzdoc, a close friend asked me some version of "but how do I know it really doesn't upload?" This post is the long answer.</p>
<p>So for <a href="https://fizzdoc.com">Fizzdoc</a> I wanted a site that <strong>can't</strong> see your file, by design. The browser itself won't let the page send it anywhere.</p>
<p>Fizzdoc now has 74 tools (PDF, Word, Excel, PowerPoint, images, audio), 16 languages and about 1,200 pages, and it has no backend at all. This post is about the design decisions that make that work, and the trade-offs I accepted.</p>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/qzqab06i1u1mpuqkxlhr.png" alt="Everything inside the red box runs in the visitor's browser tab." style="display:block;margin:0 auto" />

<h2>Constraint 1: the page is not allowed to talk to anyone</h2>
<p>Every page ships this Content Security Policy (in a <code>&lt;meta&gt;</code> tag, which the browser enforces just like a header):</p>
<pre><code class="language-plaintext">default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self'; ...
</code></pre>
<p><code>connect-src 'self'</code> is the important part. <code>fetch</code>, <code>XMLHttpRequest</code>, WebSockets, <code>sendBeacon</code>: the browser blocks all of them unless they go to <a href="http://fizzdoc.com">fizzdoc.com</a> itself, and <a href="http://fizzdoc.com">fizzdoc.com</a> is just static files. So even if one of the many open-source libraries had a bug or a malicious update, it couldn't send a document anywhere.</p>
<p>That one line shaped everything else:</p>
<ul>
<li><strong>No CDNs.</strong> Fonts, WebAssembly engines and AI models are all served from the same origin.</li>
<li><strong>No analytics scripts.</strong> Even when Cloudflare injected its own analytics beacon, the CSP blocked it, because a script that isn't from the site can't run. (I switched that beacon off too; traffic numbers come from Cloudflare's server-side stats instead.)</li>
<li><strong>It is testable.</strong> The Playwright suite (about 1,250 browser tests) runs real jobs and fails if any request leaves the origin.</li>
</ul>
<h2>Constraint 2: no server, so the build does the work</h2>
<p>There is no server to render pages, so a build step renders all of them ahead of time. One TypeScript file lists every tool (name, copy, FAQ, search keywords), and the build turns it into a static HTML page per tool per language: about 1,200 pages, each with its own title, structured data (FAQ, HowTo, breadcrumbs), hreflang links and sitemap entry.</p>
<p>The first load is small because each page only ships what it needs:</p>
<table>
<thead>
<tr>
<th>On first load</th>
<th>Gzipped</th>
</tr>
</thead>
<tbody><tr>
<td>JavaScript</td>
<td>about 19 KB</td>
</tr>
<tr>
<td>CSS</td>
<td>about 9 KB</td>
</tr>
</tbody></table>
<p>Everything heavy loads only when you use the tool that needs it, through dynamic <code>import()</code>.</p>
<h2>Each job gets its own throwaway worker</h2>
<p>The PDF tools are <a href="https://github.com/qpdf/qpdf">qpdf</a> compiled to WebAssembly. Every job gets a fresh Web Worker, which is terminated when the job ends. That keeps the page responsive, and the file and any password disappear with the worker.</p>
<p>Other engines follow the same pattern: pdf.js and pdf-lib for rendering and editing, Tesseract (WASM) for OCR, Whisper (through Transformers.js) for speech to text, and BiRefNet on ONNX Runtime Web (WebGPU when available, WASM otherwise) for background removal.</p>
<h2>Big models on a host with a 25 MiB file limit</h2>
<p>Cloudflare Pages refuses any file over 25 MiB. The background model is about 100 MB and the WebGPU build of ONNX Runtime is 27 MB.</p>
<p>So the build splits them into 20 MiB parts. On first use the worker downloads the parts, joins them, and keeps the result in Cache Storage. After that the tool starts instantly, and it works with the internet off. The build also fails if any output file goes over the limit, after one deploy failed on it.</p>
<h2>Offline without breaking updates</h2>
<p>A service worker makes every tool work offline after one visit. The rules are deliberately boring:</p>
<ul>
<li><strong>Pages:</strong> network first, cache as a fallback. Online visitors always get the latest page.</li>
<li><strong>Hashed assets</strong> (<code>/assets/index-abc123.js</code>): cache first. A hashed file never changes, so the cached copy is always right.</li>
<li><strong>Old files are never deleted.</strong> A page cached by an older version still needs its older assets.</li>
<li><strong>Models are excluded.</strong> Their tools manage their own caches.</li>
</ul>
<p>A page that was never opened shows a small "needs the internet once" page in your language instead of the browser's error screen.</p>
<h2>Hard parts that come from having no server</h2>
<p><strong>Phones run out of memory, silently.</strong> The first version of the background remover crashed Chrome on a 6 GB Android phone ("Aw, Snap!" at 5%). A tab gets far less memory than the phone has, and there's no error to catch. Phones now use a 4.6 MB model instead of the 100 MB one.</p>
<p><strong>iPhone canvases have a size limit</strong> (about 16.7 million pixels). The Photo Editor tries full size first and falls back to the largest canvas the device allows.</p>
<p><strong>Fonts couldn't be</strong> <code>data:</code> <strong>URLs.</strong> Vite inlines small files as <code>data:</code> URLs, my own CSP refused them, and the CSP-violation test caught it before it shipped.</p>
<p><strong>"Share to WhatsApp" can't make a sticker.</strong> A web page can't add stickers to WhatsApp. A shared file arrives as a photo, so the page shows WhatsApp's own "Create sticker" steps instead of a button that lies.</p>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xbk0jsiw953axdqlr35j.png" alt="The Photo Editor: all pixel maths in plain TypeScript, no image library." style="display:block;margin:0 auto" />

<h2>The editor is just pixel maths</h2>
<p>The newest tools, the <a href="https://fizzdoc.com/photo-editor/">Photo Editor</a> and the <a href="https://fizzdoc.com/collage-maker/">Collage Maker</a>, use no image library. Brightness, contrast, saturation and warmth are a short loop over the pixels. Every edit redraws from the original photo, so edits never pile up blur. Doing it in plain code instead of <code>ctx.filter</code> means every browser gives the same result, and unit tests can check it.</p>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/q03hluqkhsnyhyd1cjz5.png" alt="The Collage Maker: previews use small copies; saving decodes one photo at a time at full size." style="display:block;margin:0 auto" />

<h2>What it costs, and what 30 days of traffic look like</h2>
<p>Fizzdoc runs on Cloudflare's free plan. Hosting costs me nothing, because every page and every AI model is just a static file. These are Cloudflare's own server-side numbers for the last 30 days (there is no analytics script on the site, so this is all I can see):</p>
<table>
<thead>
<tr>
<th>Last 30 days</th>
<th></th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td>201k</td>
</tr>
<tr>
<td>Visits</td>
<td>41.9k</td>
</tr>
<tr>
<td>Page views</td>
<td>62.8k</td>
</tr>
<tr>
<td>Data served</td>
<td>10.15 GB</td>
</tr>
<tr>
<td>Server errors (5xx)</td>
<td>0%</td>
</tr>
</tbody></table>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0yakvoiax2uhm7abny8b.png" alt="Requests over 30 days" style="display:block;margin:0 auto" />

<p>A few things I didn't expect:</p>
<ul>
<li><strong>A big share is bots.</strong> Search crawlers (Googlebot, Applebot, Petalbot, Semrush) and AI crawlers show up all over the user-agent list. I welcome them in robots.txt, but it means "42k visits" is not 42k people. Treat these as upper bounds.</li>
<li><strong>Most of the bandwidth is AI models, not pages.</strong> The pages are tiny; the 10 GB is almost all background-removal and Whisper model files that visitors download once and then keep in their browser cache.</li>
</ul>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/shlf1buyajdhgmmd3quu.png" alt="Top paths by data transfer: model files, not HTML." style="display:block;margin:0 auto" />

<ul>
<li><strong>Bots keep looking for a backend that doesn't exist.</strong> Hundreds of requests for <code>/.env</code>, <code>/wp-config.php</code>, <code>/api/</code> and <code>/login</code>. There is nothing to find, since there's no server, database or secrets behind the site.</li>
<li><strong>Visitors come from everywhere.</strong> The United States, India, Singapore, South Korea, Australia and Germany lead, and the page is in 16 languages.</li>
</ul>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/7hy82kfc412p8611qbky.png" alt="Page views by country" style="display:block;margin:0 auto" />

<h2>Trade-offs I accepted</h2>
<ul>
<li><strong>Your device does the work.</strong> A 300-page scan OCRs slower on an old phone than on a server.</li>
<li><strong>Some formats aren't possible yet.</strong> PowerPoint to PDF and old .doc/.xls files aren't supported.</li>
<li><strong>One-time downloads.</strong> The AI tools download their model once (the page shows the size first).</li>
</ul>
<h2>Try it, break it</h2>
<ul>
<li>Site: <a href="https://fizzdoc.com">https://fizzdoc.com</a></li>
<li>Code (Apache-2.0): <a href="https://github.com/kingrishabdugar/fizzdoc">https://github.com/kingrishabdugar/fizzdoc</a></li>
<li>Check it yourself: open DevTools → Network, run any tool, and watch nothing leave.</li>
</ul>
<p>If you build something similar, the one decision I'd copy is the CSP. It turns "we don't upload your file" from a promise into a property of the page. And if Fizzdoc saved you an upload, <a href="https://github.com/kingrishabdugar/fizzdoc">a star on GitHub</a> helps more people find it.</p>
]]></content:encoded></item><item><title><![CDATA[I built a background remover that never sees your photo (and it does GIFs and videos too)!]]></title><description><![CDATA[Remove photo, GIF and video backgrounds free in your browser with BiRefNet on WebGPU. Full resolution, no upload. How it works and what broke.]]></description><link>https://rishabdugarjain.hashnode.dev/i-built-a-background-remover-that-never-sees-your-photo-and-it-does-gifs-and-videos-too</link><guid isPermaLink="true">https://rishabdugarjain.hashnode.dev/i-built-a-background-remover-that-never-sees-your-photo-and-it-does-gifs-and-videos-too</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Artificial Intelligence]]></category><dc:creator><![CDATA[Rishab Dugar Jain]]></dc:creator><pubDate>Sat, 07 Nov 2026 13:44:15 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/4938c8d6-fd8b-4da4-9262-2b2c618ec1bd.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When you’re the only engineer in the family, you automatically become the unpaid IT department, video producer, and resident graphic designer, because to relatives, "computers" is just one big subject (and that includes everything to do with mobile phones too... somehow!). So whenever someone needed a transparent PNG or a quick cutout, it fell on me, and I’d get stuck in the same annoying loop. I'd hunt down a "free" background remover (whatever showed up first on Google), upload the photo, wait, and then get held hostage by a "Buy Credits" button. My only options were settling for a blurry, postage-stamp-sized preview, or making a whole new account if I was desperate enough for the high-res version. Worse, somewhere in that mess, a family photo would get shipped off to a sketchy server I know nothing about.</p>
<p>For a product shot, that's annoying. For a passport photo, a signature, or a client's photo, it's a bit more than annoying.</p>
<p>So I added a background remover to <a href="https://fizzdoc.com">Fizzdoc</a>, my open-source toolkit where every tool runs inside the browser tab. It now does:</p>
<ul>
<li><strong>Photos:</strong> <a href="https://fizzdoc.com/remove-background/">Remove Background</a>, saved at the photo's own resolution as PNG, JPG or WebP.</li>
<li><strong>Animated GIFs:</strong> <a href="https://fizzdoc.com/remove-gif-background/">Remove GIF Background</a>, every frame, same timing, same loop.</li>
<li><strong>Videos:</strong> <a href="https://fizzdoc.com/remove-video-background/">Remove Video Background</a>, every frame, with the sound kept.</li>
<li><strong>WhatsApp stickers:</strong> <a href="https://fizzdoc.com/whatsapp-sticker-maker/">WhatsApp Sticker Maker</a>, a 512 × 512 WebP with a white outline, kept under 100 KB, with words in fun fonts and emoji you drag into place.</li>
</ul>
<p>You can keep the background clear, or put white, any colour, a blur or another photo behind. It's free, there's no sign-up, no watermark, and no limit on how many photos you do. The size limit comes from your device's memory, not from me.</p>
<p>And the photo never leaves your device. Not "we delete it after an hour". It just never goes anywhere.</p>
<p>Below is how it works, and what broke along the way.</p>
<h2>The rule: nothing gets uploaded, and you can check</h2>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6ozs6z0kkoy1n1ahqle5.png" alt="The Remove Background page. The badge under the title says what happens to your photo: 0 bytes uploaded." style="display:block;margin:0 auto" />

<p>Fizzdoc is a static site on Cloudflare Pages. There's no backend, so there's nothing that <em>could</em> receive a file. The page also ships a Content Security Policy with <code>connect-src 'self'</code>, which means the browser itself blocks the page from talking to any other website.</p>
<p>That rule shaped everything below. No CDN for the model, no hosted inference API, no "just this one request". Open DevTools, go to the Network tab, remove the background, and you'll see the model files coming from <a href="http://fizzdoc.com">fizzdoc.com</a> and nothing going out.</p>
<h2>Picking a model</h2>
<p>I wanted three things:</p>
<ol>
<li>Good edges on hair and fur, because that's where background removers usually show their seams.</li>
<li>Small enough to download once into a browser.</li>
<li>A licence that lets anyone use the result, including for work.</li>
</ol>
<p>I went with <a href="https://github.com/ZhengPeng7/BiRefNet"><strong>BiRefNet</strong></a> (MIT licence), the lite variant, using the 512 × 512 ONNX export by <a href="https://huggingface.co/studioludens/birefnet-lite-512">studioludens</a>. I store its big weight tensors as 16-bit floats and cast them back to 32-bit when the model loads, which halves the download to about 100 MB without changing the maths it runs.</p>
<p>For phones there's a second model, <a href="https://github.com/xuebinqin/U-2-Net"><strong>U²-Net</strong></a> <strong>small</strong> (Apache-2.0), just 4.6 MB. I learned this one the hard way: someone sent me a screenshot of an early version on a 6 GB Android phone, where Chrome killed the tab at 5% ("Aw, Snap!"). A browser tab on a phone gets far less memory than the phone has, and when it runs out there's no error to catch, the tab just dies. So now phones always use the small model, and the page says so ("for the cleanest edges on hair and fur, use a laptop"). If the big model ever crashes a tab on a laptop too, the page remembers it for 30 days and uses the small one there.</p>
<p>And for laptops with a graphics chip there's the opposite: a <strong>Sharper edges (hair, fur)</strong> button. It reruns the photo through a 1024 × 1024 export of the same BiRefNet-lite model (<a href="https://huggingface.co/onnx-community/BiRefNet_lite-ONNX">onnx-community</a>, MIT). It's a one-time 135 MB download and needs WebGPU, so it's never offered on phones. If the device can't manage it, you keep the result you already had.</p>
<h2>Running it: WebGPU when it can, CPU when it can't</h2>
<p>The model runs with <a href="https://github.com/microsoft/onnxruntime"><strong>ONNX Runtime Web</strong></a> inside a Web Worker, so the page never freezes.</p>
<ul>
<li>If the device has a graphics chip with <strong>WebGPU</strong>, the model runs there. This is the fast path.</li>
<li>If WebGPU isn't there (or the driver says no), the <strong>same model</strong> runs on the CPU through WebAssembly.</li>
<li>If that fails too, usually because the device ran out of memory, the small U²-Net model takes over.</li>
</ul>
<p>So the chain is GPU, then the same model on CPU, then the small model. Nobody gets an error just because their laptop is old.</p>
<h3>Two fun problems with "self-host everything"</h3>
<p><strong>Cloudflare Pages has a 25 MiB limit per file.</strong> The model is about 100 MB and ONNX Runtime's WebGPU build is a 27 MB <code>.wasm</code>. So both live on the server as 20 MiB parts: the models are split by a small script when I add them, and the runtime is split at build time. The worker downloads the parts, joins them, and keeps the result in the browser's Cache Storage, so the next time it starts straight from there, even with the internet off. The build also fails if any file it writes goes over 25 MiB, after one deploy failed on it.</p>
<p><strong>A runtime that exists in pieces.</strong> ONNX Runtime normally fetches its <code>.wasm</code> as one file. Mine exists just as parts on the server, so the worker joins them from Cache Storage and hands the bytes to the runtime directly with <code>ort.env.wasm.wasmBinary</code>. It needs no extra hosting and no change to the security policy.</p>
<h2>The part I'm proudest of: full resolution from a 512 px model</h2>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/p5aj4yuv4j87y4rxega1.png" alt="Top: the page loading the model once, and the options panel. Bottom: the original photo, the cut-out, and a new background in one tap." style="display:block;margin:0 auto" />

<p>The model looks at your photo squeezed to 512 × 512 and returns a soft mask. If you stretch that mask back over a 4000 × 3000 photo, the edges look like they were cut with a butter knife.</p>
<p>So the mask goes through two classic image-processing filters, both plain arithmetic on typed arrays in the worker:</p>
<ol>
<li><strong>Fast guided filter</strong> (<a href="https://arxiv.org/abs/1505.00996">He and Sun, 2015</a>). It uses the photo itself as a guide to snap the blurry mask onto the real edges, so hair follows the actual strands. The fit is computed on a smaller copy (up to 1280 px) and then applied to every pixel of the full photo in one pass.</li>
<li><strong>Blur-fusion foreground estimation</strong> (<a href="https://doi.org/10.1109/ICIP42928.2021.9506164">Forte, 2021</a>). Edge pixels are a mix of your subject and the old background. Put a cut-out on a new colour without fixing that, and you get a faint halo of the old background. This step estimates the subject's true colour at the edges, so the new background looks like it was always there.</li>
</ol>
<p>By default the result is saved at your photo's own size: your own pixels, with a cleaner mask.</p>
<h2>When the AI gets it almost right</h2>
<p>It's an AI model, so a busy background or a subject that blends into it can confuse it. For photos there's a small set of touch-ups:</p>
<ul>
<li><strong>Erase / Restore</strong> brush, with <strong>Undo</strong>, <strong>Redo</strong> and <strong>Zoom</strong> for careful work on a phone.</li>
<li><strong>Tap to remove</strong> and <strong>Tap to keep</strong>: one tap picks the whole connected area of a similar colour and removes it, or brings it back.</li>
<li><strong>Shadow</strong> and a white <strong>Outline</strong>, if you want the cut-out to stand out.</li>
<li><strong>Crop</strong>: the whole photo, fit to the subject, or a square around the subject for online shop listings.</li>
<li><strong>Hold to see the original</strong>, to compare before and after.</li>
</ul>
<h2>GIFs: every frame, same rhythm</h2>
<p>Animated GIFs are sneaky. Many frames aren't full pictures, just small patches drawn over the previous frame, with rules ("disposal methods") about what to clear afterwards.</p>
<ul>
<li><a href="https://github.com/matt-way/gifuct-js"><strong>gifuct-js</strong></a> (MIT) decodes the frames, and Fizzdoc composes each one into a full picture the way a browser would show it.</li>
<li>Each frame goes through the same model (in a faster mode, since there can be dozens).</li>
<li><a href="https://github.com/mattdesl/gifenc"><strong>gifenc</strong></a> (MIT) writes the new GIF, keeping every frame's delay and the loop.</li>
</ul>
<p>One catch: a GIF pixel is either fully see-through or fully solid. So on a clear background the edges are a little sharper than in a PNG. Pick a colour or a photo behind and the soft edges are back.</p>
<h2>Videos: every frame, sound kept</h2>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/m7zws0qibirp7kft78oa.png" alt="Remove Video Background: every frame gets a new background, and the sound is kept." style="display:block;margin:0 auto" />

<p>For video, <a href="https://github.com/Vanilagy/mediabunny"><strong>Mediabunny</strong></a> (MPL-2.0) reads the file and the browser's own WebCodecs encoder writes the new one. Every frame goes through the model and gets its new background, and the soundtrack is carried over.</p>
<p>The output is MP4 (H.264) if the browser can handle it; otherwise, it's WebM (VP9). Before you start, the page shows a preview frame and an estimate of how long the whole clip will take on that device.</p>
<p>What does "no background" mean for a video? Truly see-through video would need the encoder library to start helper workers from <code>blob:</code> URLs, which my page's security policy blocks, and most players can't show it anyway. So "None" gives you a <strong>green screen</strong>, which any video editor removes in one click. I chose to keep the policy strict rather than loosen it for one feature.</p>
<h2>Stickers: words, emoji, and the last step in WhatsApp</h2>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/3br9eeprohzz237iam1y.png" alt="The WhatsApp Sticker Maker turns any photo into a 512 × 512 sticker with a white outline, under 100 KB." style="display:block;margin:0 auto" />

<p>The <a href="https://fizzdoc.com/whatsapp-sticker-maker/">WhatsApp Sticker Maker</a> starts from the same cut-out. It squares it to 512 × 512, adds a white outline, and keeps the WebP under 100 KB by lowering the quality step by step until it fits.</p>
<p>On top of that, you can add words in five styles (Funny, Cute, Cozy, BIG, and Serious) and emojis. Each one is a layer you drag, pinch, resize and rotate, on a phone or with a mouse. The fonts are self-hosted like everything else: Bangers, Kalam, Teko and Inter, with Baloo 2 covering Hindi letters. Emoji are drawn with your device's own emoji font.</p>
<p>One fun bug on the way: Vite inlines small files as <code>data:</code> URLs, and a few of the font files were small enough. My own CSP (<code>default-src 'self'</code>) refused them, and the end-to-end test that fails on any CSP violation caught it before it shipped. Fonts are now always separate files.</p>
<p>One limit, though. A web page can't add a sticker to WhatsApp, and a sticker shared from the browser arrives as a normal photo. So instead of a "Share to WhatsApp" button that doesn't do what it says, the page shows WhatsApp's own steps: save the sticker, then in any chat tap Stickers › Create and pick it. On iPhone there's a "Save to Photos" button, because downloads go to Files and WhatsApp picks stickers from Photos.</p>
<h2>What it's like on low-end phones</h2>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ayc0gdg72seyd3vy5kuc.png" alt="The same tool on a phone, in dark mode. Phones use the small 4.6 MB model so the tab doesn't crash." style="display:block;margin:0 auto" />

<p>I care about this a lot because many people open Fizzdoc on Android phones.</p>
<ul>
<li>Phones always get the small model, so it works rather than crashes. Edges are a bit softer than on a laptop, which is a trade I'll take every time.</li>
<li>The worker keeps the page responsive while it thinks.</li>
<li>The model downloads once (the page shows the progress in MB). After that it starts right away, and it keeps working with Wi-Fi and mobile data off, which is also the easiest way to check that nothing is uploaded.</li>
<li>Videos give you a time estimate first: short clips are fine, long ones are a laptop job.</li>
</ul>
<h2>Things it doesn't do (yet)</h2>
<ul>
<li>Truly transparent video (see above, green screen instead).</li>
<li>Many photos in one go: the background remover takes one photo at a time.</li>
<li>Several photos in one sticker. That's next.</li>
</ul>
<h2>Credits</h2>
<p>None of this would exist without these people and projects. Thank you:</p>
<table>
<thead>
<tr>
<th>Project</th>
<th>What it does in Fizzdoc</th>
<th>Licence</th>
</tr>
</thead>
<tbody><tr>
<td><a href="https://github.com/ZhengPeng7/BiRefNet">BiRefNet</a> by Peng Zheng and co-authors</td>
<td>The background model</td>
<td>MIT</td>
</tr>
<tr>
<td><a href="https://huggingface.co/studioludens/birefnet-lite-512">birefnet-lite-512</a> by studioludens</td>
<td>The 512 px ONNX export</td>
<td>MIT</td>
</tr>
<tr>
<td><a href="https://huggingface.co/onnx-community/BiRefNet_lite-ONNX">BiRefNet_lite-ONNX</a> by onnx-community</td>
<td>The 1024 px export for Sharper edges</td>
<td>MIT</td>
</tr>
<tr>
<td><a href="https://github.com/xuebinqin/U-2-Net">U²-Net</a> by Xuebin Qin and co-authors</td>
<td>The small model for phones</td>
<td>Apache-2.0</td>
</tr>
<tr>
<td><a href="https://github.com/microsoft/onnxruntime">ONNX Runtime Web</a> by Microsoft</td>
<td>Runs the models on WebGPU or WebAssembly</td>
<td>MIT</td>
</tr>
<tr>
<td><a href="https://github.com/matt-way/gifuct-js">gifuct-js</a> by Matt Way</td>
<td>Reads animated GIFs</td>
<td>MIT</td>
</tr>
<tr>
<td><a href="https://github.com/mattdesl/gifenc">gifenc</a> by Matt DesLauriers</td>
<td>Writes animated GIFs</td>
<td>MIT</td>
</tr>
<tr>
<td><a href="https://github.com/Vanilagy/mediabunny">Mediabunny</a> by Vanilagy</td>
<td>Reads and writes videos</td>
<td>MPL-2.0</td>
</tr>
<tr>
<td><a href="https://arxiv.org/abs/1505.00996">Kaiming He and Jian Sun</a></td>
<td>The (fast) guided filter</td>
<td>Paper</td>
</tr>
<tr>
<td><a href="https://github.com/Photoroom/fast-foreground-estimation">Marco Forte</a></td>
<td>Blur-fusion foreground estimation</td>
<td>Paper</td>
</tr>
</tbody></table>
<p>The full licence texts are in Fizzdoc's <a href="https://fizzdoc.com/third-party-licenses.txt">third-party licences</a>.</p>
<h2>Try it, break it, tell me</h2>
<ul>
<li>Photo: <a href="https://fizzdoc.com/remove-background/">https://fizzdoc.com/remove-background/</a></li>
<li>GIF: <a href="https://fizzdoc.com/remove-gif-background/">https://fizzdoc.com/remove-gif-background/</a></li>
<li>Video: <a href="https://fizzdoc.com/remove-video-background/">https://fizzdoc.com/remove-video-background/</a></li>
<li>WhatsApp sticker: <a href="https://fizzdoc.com/whatsapp-sticker-maker/">https://fizzdoc.com/whatsapp-sticker-maker/</a></li>
<li>Code (Apache-2.0): <a href="https://github.com/kingrishabdugar/fizzdoc">https://github.com/kingrishabdugar/fizzdoc</a></li>
</ul>
<p>It's part of a bigger toolkit of 68 free tools (PDF, Word, Excel, PowerPoint, images and audio), all running in your browser, in 16 languages. If something looks off on your device, there's a "Report it" link on every tool. And if you like it, a star on GitHub genuinely makes my day.</p>
]]></content:encoded></item><item><title><![CDATA[I put 64 file tools and an open-weight AI model in a browser tab. Nothing gets uploaded.]]></title><description><![CDATA[How Fizzdoc runs 64 PDF, Office, image and audio tools plus Whisper in the browser with WebAssembly, and the Android bug that made splits take 6 min.]]></description><link>https://rishabdugarjain.hashnode.dev/i-put-64-file-tools-and-an-open-weight-ai-model-in-a-browser-tab-nothing-gets-uploaded</link><guid isPermaLink="true">https://rishabdugarjain.hashnode.dev/i-put-64-file-tools-and-an-open-weight-ai-model-in-a-browser-tab-nothing-gets-uploaded</guid><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[WebAssembly]]></category><category><![CDATA[privacy]]></category><category><![CDATA[Open Source]]></category><dc:creator><![CDATA[Rishab Dugar Jain]]></dc:creator><pubDate>Sat, 07 Nov 2026 13:40:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aef1ef8f07c51fd486092bf/cec97c1d-8de8-4575-9bfe-ac1ae9f72f51.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Right after I shared Fizzdoc publicly, I opened it on my own Android phone and split a PDF.</p>
<p>It took six minutes.</p>
<p>Six. On my laptop the same kind of job took about a second. The link was already out in public, so somewhere out there, people were staring at a progress bar that didn't exist and thinking "nice project, Rishab."</p>
<p>I'll tell you how I fixed it. But first, what this thing even is, and why I think it fits this year's Hacktoberfest theme, "AI belongs to everyone", better than I planned.</p>
<img src="https://raw.githubusercontent.com/kingrishabdugar/fizzdoc/main/docs/assets/hero.png" alt="Fizzdoc home page" style="display:block;margin:0 auto" />

<h2>What is Fizzdoc?</h2>
<p><a href="https://fizzdoc.com">Fizzdoc</a> is 64 tools for PDF, Word, Excel, PowerPoint, images and audio. Merge, compress, convert, edit, redact, OCR, cut an MP3, turn a voice note into text. The usual suspects, in 16 languages.</p>
<p>None of it uploads your file, though. There's no backend. Not a small one, not a "we only keep it for an hour" one. Nothing. It's a static site, and every job runs inside your browser tab, on your own device.</p>
<p>Most "free PDF" sites work like this: you send them your passport scan (or your Aadhaar card, if you're in India), they send you back a slightly smaller one, and you hope for the best. That always felt like a strange deal for jobs a phone can do on its own. So I built the other version.</p>
<p>Oh, and it runs an open-weight AI model (Whisper) right in the tab to turn audio into text. No API key, no GPU, no server. If AI belongs to everyone, it should also run on everyone's phone.</p>
<p>It's open source (Apache-2.0): <a href="https://github.com/kingrishabdugar/fizzdoc">github.com/kingrishabdugar/fizzdoc</a>. Everything in this post is in that repo, and every number comes from the code or something I actually measured.</p>
<h2>Where Fizzdoc fits</h2>
<p>The open-source world already has great PDF tools, and I've used several. Some are big platforms you self-host with Docker and run for a whole team, with pipelines, APIs and logins. Some are lean, browser-only PDF kits. Both are good at what they do.</p>
<p>Fizzdoc makes a different bet:</p>
<ul>
<li><strong>Nothing to install. Not even Docker.</strong> Open a link on any phone or laptop and it works. That matters when the person who needs to unlock a PDF is your dad, not your homelab.</li>
<li><strong>Not just PDF.</strong> 30 PDF tools, 14 for images, 14 for Word, Excel and PowerPoint, and 6 for audio, including speech to text and subtitles. Plus developer odds and ends like Excel to JSON and Mermaid to PNG.</li>
<li><strong>Your language, not just English.</strong> 16 languages, from Spanish, French, German and Portuguese to Hindi, Bengali, Tamil and Telugu, each with its own URLs, so people can find it by searching the way they actually talk.</li>
<li><strong>Built for cheap phones first.</strong> More on that below, because I learned it the hard way.</li>
<li><strong>Privacy you can verify.</strong> A strict CSP and a test that fails if any request leaves the site.</li>
</ul>
<p>If you need automation pipelines or an admin panel for your company, a self-hosted platform is the right call. If you need to shrink a photo to 50 KB for a government form on your phone in the next 30 seconds, that's what this is for.</p>
<h2>The whole thing on one slide</h2>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0bk2ipjt7mxyyqq613yc.png" alt="How Fizzdoc is built: build time, static host, browser tab" style="display:block;margin:0 auto" />

<p>Three layers, and only the last one ever touches your file.</p>
<p><strong>Build time.</strong> One TypeScript file, <code>src/site.ts</code>, describes all 64 tools: slug, formats, options, page text. A renderer turns that into about 1,050 plain HTML pages (64 tools × 16 languages, plus a few extras), with the sitemap and <code>llms.txt</code> thrown in. Adding a tool is one registry entry, one engine function and tests. The pages write themselves, which is the only kind of page I enjoy writing.</p>
<p><strong>Hosting.</strong> Cloudflare Pages. It serves files and answers GET requests. That's its entire personality.</p>
<p><strong>Your browser.</strong> A small script handles the boring parts: picking files, options, progress, download. It has no idea what a PDF is. When you press the button, it borrows a specialist:</p>
<table>
<thead>
<tr>
<th>Engine</th>
<th>Job</th>
<th>Download (gzipped)</th>
</tr>
</thead>
<tbody><tr>
<td>qpdf (C++, compiled to WebAssembly)</td>
<td>merge, split, rotate, passwords</td>
<td>~440 KB</td>
</tr>
<tr>
<td>pdf.js</td>
<td>drawing pages, reading text</td>
<td>~390 KB</td>
</tr>
<tr>
<td>pdf-lib</td>
<td>page numbers, watermarks, building PDFs</td>
<td>small</td>
</tr>
<tr>
<td>Tesseract.js</td>
<td>OCR</td>
<td>~5.8 MB</td>
</tr>
<tr>
<td>Whisper tiny</td>
<td>speech to text</td>
<td>~58 MB, once</td>
</tr>
<tr>
<td>fflate</td>
<td>Word, Excel, PowerPoint (secretly ZIP files)</td>
<td>small</td>
</tr>
<tr>
<td>Mermaid</td>
<td>diagrams to PNG/SVG</td>
<td>per diagram type</td>
</tr>
</tbody></table>
<h2>"Just use a server, bro"</h2>
<p>I know. A server would have been easier. I chose pain for three reasons.</p>
<p><strong>You can check it.</strong> "We delete your files" is a promise. This is a rule your browser enforces:</p>
<pre><code class="language-text">default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
connect-src 'self';
img-src 'self' data: blob:;
object-src 'none'; base-uri 'none'; form-action 'none'
</code></pre>
<p><code>connect-src 'self'</code> means the page can only talk to <a href="http://fizzdoc.com">fizzdoc.com</a>, and <a href="http://fizzdoc.com">fizzdoc.com</a> only serves the website. Open DevTools, go to Network, run a job, and watch nothing interesting happen. The test suite does the same thing on every pull request and fails if a single request goes anywhere else.</p>
<p><strong>It's free to run.</strong> A file-processing server gets more expensive with every user. A static site doesn't care how popular it gets. That's the only reason I can say "free" without a pricing page hiding behind it.</p>
<p><strong>No waiting in line.</strong> No upload bar, no "processing on our servers", no download. Your file goes from your disk to your RAM and back.</p>
<p>The catch is obvious: your device does the work, and not everyone has a laptop. Which brings us back to my six-minute disaster.</p>
<h2>The Android bug</h2>
<p><a href="https://github.com/qpdf/qpdf">qpdf</a> runs on <a href="https://github.com/emscripten-core/emscripten">Emscripten</a>, which gives it a small pretend file system. I was handing it your file through <strong>WORKERFS</strong>, which reads the file lazily, only when qpdf asks for a piece. Sounds efficient, very "don't load what you don't need", and I was proud of it.</p>
<p>What I didn't know: qpdf doesn't ask for a few big pieces. It asks for lots and lots of tiny ones. On a 10 MB, 28-page test PDF, I counted <strong>1,448 separate reads</strong>: 434 just to count the pages, and 1,014 more for the split.</p>
<p>On a laptop, 1,448 small reads is nothing. On Android, reading a picked file in small pieces is slow, and 1,448 slow things in a row add up to minutes. My "efficient" design was death by a thousand paper cuts (literally, since it was a PDF).</p>
<p>The fix was almost embarrassing:</p>
<pre><code class="language-ts">// qpdf reads its input in thousands of small pieces, and on Android every small read
// of a picked file is slow. Only inputs too big to copy safely are still read lazily.
const COPY_UP_TO = 128 * 1024 * 1024;

if (totalSize &lt;= COPY_UP_TO) await copyIn(q, files, run); // read once, in order, into memory
else q.FS.mount(q.WORKERFS, { blobs }, '/in');            // huge files: no second copy
</code></pre>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/j87ktfnpwl52ai3a3sml.png" alt="How a PDF gets into memory: budget check, then copy or lazy read" style="display:block;margin:0 auto" />

<p>If your files add up to 128 MB or less, the worker reads them once, start to finish, into memory. All those tiny reads now hit RAM, which doesn't care. Same test file: 1,448 reads became zero, and even on my laptop the split went from 1.4 s to 0.25 s. So the bug was slowing everyone down; phones were just louder about it.</p>
<p>Above 128 MB, making a second full copy could run a phone out of memory, so big files still get the lazy path. Slower, but it finishes. I tested that with a 140 MB PDF, and yes, I waited.</p>
<h2>Being nice to a ₹8,000 phone</h2>
<p>That bug taught me to design for the cheapest phone first, not my laptop. This is what changed because of it.</p>
<h3>Load almost nothing</h3>
<p>The first page load is about <strong>15 KB of gzipped JavaScript and 7 KB of CSS</strong>. No framework. Every page is prerendered HTML, so you can read it before any script runs (Google can too).</p>
<p>The Inter font is split into 7 <code>unicode-range</code> pieces with <code>font-display: swap</code>, so your browser only downloads the characters a page actually uses, and shows text in a system font while it waits. A tiny <code>theme.js</code> sets dark mode before the first paint, so no white flash at 2 a.m. Hashed files are cached for a year, so the second visit is mostly just HTML.</p>
<h3>Load engines only when asked</h3>
<p>Each tool's engine hides behind a dynamic <code>import()</code>:</p>
<pre><code class="language-ts">'compress-pdf': async (f, o) =&gt; (await import('./engine/compress')).compressPdf(f[0], { ... }),
'ocr-pdf':      async (f) =&gt; (await import('./engine/ocr')).ocrPdf(f[0], progressLabel),
</code></pre>
<p>If all you do is rotate a PDF, you never download the OCR engine or a 58 MB speech model. Mermaid is even pickier: every diagram type is its own chunk, so drawing a flowchart doesn't drag in the code for sequence diagrams.</p>
<h3>Check the budget before you spend it</h3>
<p>Before a job starts, the page does a quick sanity check:</p>
<pre><code class="language-ts">// 128 MiB per GiB of reported device memory, capped at 1 GiB.
function inputBudget() {
  const gib = navigator.deviceMemory;
  return gib ? Math.min(gib * 128, 1024) * MiB : 512 * MiB;
}
</code></pre>
<p>A 4 GB phone gets 512 MiB, an 8 GB laptop gets 1 GiB. Firefox and Safari don't report device memory, so they get a sensible 512 MiB. The cap exists because qpdf's WebAssembly memory tops out at 2 GiB and a job needs room for more than its input.</p>
<p>Is it a perfect measurement? No, and the code comment admits that. But "this file is too big for this device" is a much better message than a tab that quietly dies after five minutes.</p>
<h3>Show real progress</h3>
<p>A spinner on a slow phone is basically a "maybe" button. So the progress bar is real.</p>
<p>The first 30% is the file being read into memory, byte by byte. The other 70% comes from qpdf itself. qpdf has a <code>--progress</code> flag that prints lines like <code>write progress: 42%</code>. This WebAssembly build ignores the option meant to catch that output, so it just goes to <code>console.log</code>. Okay then, I'll grab it from there:</p>
<pre><code class="language-ts">console.log = (...args) =&gt; {
  const pct = /: write progress: (\d+)%$/.exec(String(args[0]))?.[1];
  if (pct === undefined) return log(...args);
  post({ type: 'progress', fraction: READ_SHARE + ((1 - READ_SHARE) * Number(pct)) / 100 });
};
</code></pre>
<p>Is hijacking <code>console.log</code> elegant? Absolutely not. Does the bar move now? It does. I sleep fine.</p>
<h3>One job, one worker, then gone</h3>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kxguikttab6kqgem8a6m.png" alt="Life of a job: worker per job, terminate to clean up" style="display:block;margin:0 auto" />

<p>Every qpdf job gets its own fresh Web Worker, so the page stays smooth while it works. When the job ends, or you hit Cancel, the page calls <code>worker.terminate()</code>. That one line throws away the file, any password you typed, and the engine's whole memory, so the cancel button doubles as cleanup and leak prevention. No state survives between jobs, so memory doesn't slowly creep up while you merge your way through tax season.</p>
<p>Before running, the worker also asks qpdf what's in the file: bookmarks, forms, signatures, encryption. If you merge a signed PDF, Fizzdoc tells you the signature won't be valid anymore, instead of letting you find out from your CA.</p>
<h3>Only draw what's on screen</h3>
<p>The editor and the redaction tool show every page of your PDF. On a 200-page file that could mean 200 canvases fighting for memory. Instead, a page is drawn only when it's about to scroll into view:</p>
<pre><code class="language-ts">const io = new IntersectionObserver(
  (entries) =&gt; { for (const e of entries) if (e.isIntersecting) renderPage(pages[e.target.dataset.index]); },
  { root: scroller, rootMargin: '600px 0px' },
);
</code></pre>
<p>Tools that must touch every page (PDF to JPG, scanned PDF, OCR) go one page at a time and clean each page up before moving on.</p>
<h3>Do less work</h3>
<ul>
<li><strong>MP3 and M4A are cut without re-encoding.</strong> An MP3 is a row of little frames, each saying how long it is. Cutting is just picking the frames between two timestamps. No decoding, no quality loss, and fast even on a phone.</li>
<li><strong>Compression never makes things bigger.</strong> Each image inside a PDF is kept only if the new version is smaller. If the whole file doesn't shrink, you get your original back, not a "compressed" file that's 3% larger. (We've all seen that one.)</li>
<li><strong>"Compress image to 50 KB" is smart about it.</strong> It keeps the photo large and lowers quality first, never below 50% while it's 1000 px or wider, using a 6-step binary search. Only if that can't fit does it shrink the size. Roughly how messaging apps do it.</li>
</ul>
<h3>And when it's slow anyway, say so</h3>
<p>While a job runs, the page tells you speed depends on your device. If something takes over 20 seconds or fails, a "Slow or not working? Report it on GitHub" link lights up. It opens a ready-made issue with the tool, browser, time taken, file size, and the device's RAM and CPU cores. Never the file, never its name. That link is how I find the next bug instead of guessing.</p>
<p>What's still not great: PDF to JPG keeps every page image in memory until the ZIP is built (a 1,000-page PDF on a phone would be a bad day), some jobs still run on the main thread, and Whisper runs on a single thread. Speaking of which.</p>
<h2>Speech to text, without a server</h2>
<p>This was the feature I wasn't sure a static site could pull off.</p>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6uflsc2txovtu1c3y9we.png" alt="Speech to text pipeline: download once, cache, decode, 30 s windows" style="display:block;margin:0 auto" />

<p>Fizzdoc runs Whisper tiny (8-bit quantized) with transformers.js and ONNX Runtime, on the CPU, inside a worker. It took three fights to get there.</p>
<p><strong>Cloudflare Pages allows 25 MiB per file.</strong> The model is bigger. So a build script chops the model into parts. The first time you use the tool, the browser downloads the parts, glues them back together and keeps them in Cache Storage. After that it starts in seconds, even offline.</p>
<p><strong>My own security rules blocked it.</strong> ONNX Runtime likes to load its WebAssembly through a <code>blob:</code> URL, which my CSP refuses. So the worker reads the file from the cache itself and hands it over directly. Remote model loading is switched off too, so it can only read from <a href="http://fizzdoc.com">fizzdoc.com</a>.</p>
<p><strong>Nobody trusts a spinner for 58 MB.</strong> The first run says "Preparing… one-time setup: 23 of 58 MB" with a real byte counter, and tells you it only happens once.</p>
<p>Audio is turned into 16 kHz mono (what Whisper expects) and fed in 30-second windows with 5 seconds of overlap, so words at the edges don't get chopped in half.</p>
<p>Whisper tiny is, well, tiny. Clear English comes out well. Hinglish, sadly, does not. Give it Hindi and English in the same sentence and it answers with great confidence and very little accuracy. The tool now says it works best on clear English. Multi-threading would help speed, but it needs cross-origin isolation, and my first attempt made the worker hang. So it's single-threaded for now. If you've solved this, please come find me.</p>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/js47lvxn8sgv6bnsux5k.png" alt="Audio to Text page" style="display:block;margin:0 auto" />

<h2>Smaller decisions I'd defend in a code review</h2>
<p><strong>Redaction that actually redacts.</strong> Drawing a black box over text doesn't remove it. People still do that, and people still copy the text out from under the box. Fizzdoc turns each page into an image with the boxes burned in, then builds a new PDF from those images. The text is really gone. The trade-off is the result isn't selectable text, and the tool says so. There's also an optional "find personal info" button that marks emails, UPI IDs, phone numbers, PAN, IFSC, IBAN and long ID numbers. It's plain regex, not AI, and you check every mark before saving.</p>
<p><strong>Editing text in place, in the PDF's own font.</strong> Click a line in a PDF and retype it. On save, Fizzdoc parses the page's content stream, tracks the text state (matrices, font, spacing) to find the exact operators that draw that line, and rewrites them using the font that's already embedded in the file. Same font, same size, same colour, no white box. The catch: embedded fonts are usually subsets, so you can only use letters the file already contains. Change "Rs 4,250" to "Rs 2,450"? Easy. Type a "Z" into a PDF that never had one? The glyph simply isn't there, so that line falls back to covering the old text and drawing new text in the closest standard font. Hindi and Arabic always take the fallback too, because their glyphs are drawn in a different order than they're typed, and I'd rather be boring than garbled.</p>
<img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/0njstg0epqbd56v90zy2.png" alt="Before and after: three lines edited, same fonts and colours" style="display:block;margin:0 auto" />

<p><strong>Word to PDF via the print dialog.</strong> Drawing Hindi, Tamil or Arabic correctly inside a JavaScript PDF writer is a whole career. Every browser already ships a great PDF writer: Print → Save as PDF. So Fizzdoc turns your .docx into clean HTML and opens the print dialog. One extra click, and every script comes out right. Lazy, yes, but it works.</p>
<p><strong>qpdf instead of a JavaScript PDF library.</strong> I started with pdf-lib for merging. It works, but copying pages that way drops bookmarks, form fields and internal links. qpdf rewrites the file's structure instead, and has been doing it reliably for years.</p>
<h2>Bonus: your coding agent can use it too</h2>
<p>Hacktoberfest is big on agents this year, so here's a small one. The repo has an MCP server (in <code>/mcp</code>) that runs the same qpdf and audio engines locally. Hook it up to Claude Code, Codex or Cursor and you can say "merge these PDFs" or "cut the first 30 seconds of this MP3", and the agent does it on your machine. No upload, no throwaway Python script, and it never overwrites your files.</p>
<h2>Lots of languages, one source file</h2>
<p>Each language has real URLs (<code>/hi/merge-pdf/</code>, <code>/ta/compress-pdf/</code>), translated buttons, FAQs and error messages, and hreflang links between them: Hindi, Bengali, Marathi, Tamil, Telugu, Spanish, Portuguese, French, German, Italian, Dutch, Polish, Turkish, Indonesian, Vietnamese and English.</p>
<img src="https://raw.githubusercontent.com/kingrishabdugar/fizzdoc/main/docs/assets/lang.gif" alt="Fizzdoc in Hindi" style="display:block;margin:0 auto" />

<p>Because every page is generated, the tests can check all ~1,050 of them for their CSP, title, description, canonical link, a single <code>h1</code> and valid structured data. I'm not checking 1,050 pages by hand.</p>
<h2>Tests, because I like sleeping</h2>
<p>209 unit tests and 1,087 browser tests. Engines are plain functions (bytes in, bytes out), so most run in Node against small weird files: encrypted PDFs, forms, bookmarks, MP3s. The browser tests click through the real UI, including the "no request leaves the site" check. CI runs both on every pull request, and nothing merges red.</p>
<h2>Come build it with me</h2>
<p>Right now Fizzdoc is one person and a lot of tabs. There's plenty left, and some of it is really fun engineering. Hacktoberfest doesn't count pull requests anymore, which honestly suits me. I'd much rather have one good PR that someone understands than ten that just exist.</p>
<p><strong>Good first issues</strong></p>
<ul>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/17">Crop PDF</a>, <a href="https://github.com/kingrishabdugar/fizzdoc/issues/18">Flatten PDF</a>, <a href="https://github.com/kingrishabdugar/fizzdoc/issues/19">Split every N pages</a></li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/6">Pick the DPI for PDF → JPG</a>, <a href="https://github.com/kingrishabdugar/fizzdoc/issues/8">watermark position and colour</a></li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/22">Word → Markdown / HTML</a></li>
<li>Translations: <a href="https://github.com/kingrishabdugar/fizzdoc/issues/1">Japanese</a>, <a href="https://github.com/kingrishabdugar/fizzdoc/issues/3">Korean, Gujarati, Kannada, Malayalam, Punjabi</a></li>
</ul>
<p><strong>Bigger ones</strong></p>
<ul>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/10">Sign PDF</a></li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/13">OCR in more languages</a>, downloaded on demand like Whisper</li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/11">Install it as an offline app (PWA)</a></li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/12">Page thumbnails with drag and drop</a></li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/42">WAV, FLAC, OGG and OPUS</a> in the audio tools</li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/44">Remove image background</a>, on the device</li>
<li><a href="https://github.com/kingrishabdugar/fizzdoc/issues/5">Right-to-left languages</a></li>
</ul>
<p><strong>Problems I'd love your brain on</strong></p>
<ul>
<li>Faster Whisper: threads (COOP/COEP without the hang) or WebGPU. If you've shipped either, tell me everything.</li>
<li>A bigger or multilingual open-weight speech model that still fits a phone, so Hinglish stops being a comedy show.</li>
<li>More MCP tools: <a href="https://github.com/kingrishabdugar/fizzdoc/issues/61">the image, OCR, redaction and Office engines in Node</a>.</li>
<li>Streaming the ZIP for PDF to JPG, so huge PDFs don't sit in memory.</li>
<li>Real text deletion in Edit PDF, rewriting the content stream instead of covering it.</li>
</ul>
<p>Setup takes about as long as reading this sentence twice:</p>
<pre><code class="language-sh">git clone https://github.com/kingrishabdugar/fizzdoc
cd fizzdoc
npm install
npm run dev
</code></pre>
<p><a href="http://CONTRIBUTING.md">CONTRIBUTING.md</a> shows how a tool goes from the registry to its engine. Small pull requests get reviewed fastest. Not sure where to start? Comment on any issue and I'll help you pick one.</p>
<p><a class="embed-card" href="https://github.com/kingrishabdugar/fizzdoc">https://github.com/kingrishabdugar/fizzdoc</a></p>

<h2>That's it</h2>
<p>Try it at <a href="https://fizzdoc.com">fizzdoc.com</a>. Throw your weirdest PDF at it. If it's slow on your phone, hit the report link and tell me which phone, so I can have another humbling morning.</p>
<p>And if you think it's useful, a star on <a href="https://github.com/kingrishabdugar/fizzdoc">kingrishabdugar/fizzdoc</a> helps more people find it. It also makes me unreasonably happy.</p>
<p>Thanks for reading.</p>
]]></content:encoded></item></channel></rss>