<?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: shenghua wu</title>
    <description>The latest articles on DEV Community by shenghua wu (@sebestian).</description>
    <link>https://dev.to/sebestian</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%2F4184802%2Ff7ae8e46-dcd5-4939-9c1b-0b41109e721a.png</url>
      <title>DEV Community: shenghua wu</title>
      <link>https://dev.to/sebestian</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sebestian"/>
    <language>en</language>
    <item>
      <title>302 pages in 7 days, 29 impressions: an honest programmatic-SEO postmortem</title>
      <dc:creator>shenghua wu</dc:creator>
      <pubDate>Thu, 15 Oct 2026 07:40:51 +0000</pubDate>
      <link>https://dev.to/sebestian/302-pages-in-7-days-29-impressions-an-honest-programmatic-seo-postmortem-4372</link>
      <guid>https://dev.to/sebestian/302-pages-in-7-days-29-impressions-an-honest-programmatic-seo-postmortem-4372</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I built this site with AI coding agents, and this article was drafted with AI assistance from my own Search Console exports, crawl logs, and GitHub API queries. The numbers are copied from those sources, not estimated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search Console, first data window after launch&lt;/strong&gt;&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clicks        0
Impressions  29
Avg position 29.86
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;That is the entire search performance of a 302-page site.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a postmortem of a programmatic SEO play. Not the "we 10x'd our traffic" genre — the other one. The data is real and the conclusions cost me a week.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bet
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://toolpantry.app" rel="noopener noreferrer"&gt;Tool Pantry&lt;/a&gt;: 208 free browser tools for PDFs, images, video, audio, and text, where everything processes client-side and nothing uploads. Zero backend, zero accounts.&lt;/p&gt;

&lt;p&gt;The distribution plan was pSEO, and the reasoning was standard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Tools map cleanly onto search intent (&lt;code&gt;compress image to 200kb&lt;/code&gt;, &lt;code&gt;pdf to jpg&lt;/code&gt;, &lt;code&gt;heic to webp&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Long-tail tool queries are fragmented enough that a new site can slip into the gaps&lt;/li&gt;
&lt;li&gt;Volume wins — more indexed pages means more surface area for impressions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I optimized for shipping rate. In seven days: &lt;strong&gt;208 tools, 302 URLs, 25 long-form guides, 47 Spanish pages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I waited for Google.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week one, raw
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clicks:                    0
Impressions:              29
Average position:      29.86
Pages ever shown:          7  (of 302)
Indexed:              ~12–30  (of 302)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-nine impressions. For three hundred and two pages.&lt;/p&gt;

&lt;p&gt;Meanwhile the crawl logs were busy: &lt;strong&gt;1,176 Googlebot requests in 48 hours&lt;/strong&gt;, plus several thousand from AI crawlers. Google was absolutely reading the site. It just wasn't &lt;em&gt;ranking&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;Here is the part that reframed everything for me.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 1: The bottleneck is indexation, not ranking
&lt;/h2&gt;

&lt;p&gt;I had been thinking about this as a ranking problem — "how do I get to page one?" But the pages weren't on page four either. Most weren't in the index at all.&lt;/p&gt;

&lt;p&gt;Search Console puts every URL in one of three states:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;URL is unknown to Google&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Never discovered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Discovered – currently not indexed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Known, queued, not crawled yet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Crawled – currently not indexed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Crawled, read, and declined&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Out of 302 URLs, the overwhelming majority sat in the first bucket. Google knew about the homepage and a scattering of tool pages. The rest — 25 guides, 47 Spanish pages, most of the tool library — it had never looked at.&lt;/p&gt;

&lt;p&gt;You cannot rank for a query if you are not in the index. And I had spent the entire week doing the thing that does not fix that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 2: Word count did not predict indexation. At all.
&lt;/h2&gt;

&lt;p&gt;I had a comfortable theory: my tool pages were thin (~300 words), Google declines thin pages, so I should make them longer.&lt;/p&gt;

&lt;p&gt;Then I actually checked word count against indexation status:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Words&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/subnet-calculator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;309&lt;/td&gt;
&lt;td&gt;✅ Indexed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/inventory-spreadsheet-generator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;311&lt;/td&gt;
&lt;td&gt;✅ Indexed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/meme-generator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;410&lt;/td&gt;
&lt;td&gt;✅ Indexed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/tool-pantry-vs-smallpdf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;413&lt;/td&gt;
&lt;td&gt;✅ Indexed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/tool-pantry-vs-tinypng&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;443&lt;/td&gt;
&lt;td&gt;✅ Indexed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/tool-pantry-vs-pdf24&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;506&lt;/td&gt;
&lt;td&gt;❌ Crawled, not indexed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/tool-pantry-vs-adobe-acrobat-online&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;547&lt;/td&gt;
&lt;td&gt;❌ Crawled, not indexed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/compress-video&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;723&lt;/td&gt;
&lt;td&gt;❌ Crawled, not indexed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The correlation is &lt;strong&gt;backwards&lt;/strong&gt;. The shortest pages got indexed and ranked; the longest got declined.&lt;/p&gt;

&lt;p&gt;The cleanest control is the &lt;code&gt;vs-*&lt;/code&gt; pages: eight pages built from an identical template on the same day, with near-identical length. &lt;code&gt;vs-smallpdf&lt;/code&gt; (413 words) and &lt;code&gt;vs-tinypng&lt;/code&gt; (443 words) indexed. &lt;code&gt;vs-pdf24&lt;/code&gt; (506 words) and &lt;code&gt;vs-adobe-acrobat-online&lt;/code&gt; (547 words) did not.&lt;/p&gt;

&lt;p&gt;Same structure, same length, same day, same site. Split roughly down the middle.&lt;/p&gt;

&lt;p&gt;My "thin content" theory was dead. If content depth were the gate, this table would look like the opposite of what it looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 3: &lt;code&gt;Crawled – currently not indexed&lt;/code&gt; is a trust verdict, not a quality verdict
&lt;/h2&gt;

&lt;p&gt;That label means Google fetched the page, parsed it, and decided not to spend index space on it. It reads like "your page isn't good enough." For a new domain, it usually means something else: &lt;strong&gt;"I don't yet have enough reason to trust this site."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The evidence is in the progression. Watching the same URLs over three days:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/compress-pdf        unknown → indexed → indexed
/video-to-mp3        unknown → indexed → indexed
/tool-pantry-vs-smallpdf   unknown → unknown → indexed
/tool-pantry-vs-tinypng    unknown → unknown → indexed
/tool-pantry-vs-pdf24      unknown → unknown → crawled, declined
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pages are &lt;em&gt;moving through a queue&lt;/em&gt;. It's not a verdict on any individual page — it's Google metering how much index space a brand-new, zero-authority domain has earned, and releasing it in batches.&lt;/p&gt;

&lt;p&gt;That reframes the fix entirely. Improving page quality doesn't make the queue move faster. &lt;strong&gt;Increasing the domain's trust does.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 4: The backlink strategy was a graveyard
&lt;/h2&gt;

&lt;p&gt;The one lever I did have was backlinks, and the plan was "awesome lists" — submit the project to curated GitHub lists, get dofollow links from READMEs, accumulate authority.&lt;/p&gt;

&lt;p&gt;I filed 21 PRs across six repositories.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merged: zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then I checked whether they &lt;em&gt;could&lt;/em&gt; merge. Open PR counts and last-merge dates:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Repository&lt;/th&gt;
&lt;th&gt;Stars&lt;/th&gt;
&lt;th&gt;Open PRs&lt;/th&gt;
&lt;th&gt;Last merged anything&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ComposioHQ/awesome-claude-skills&lt;/td&gt;
&lt;td&gt;76K&lt;/td&gt;
&lt;td&gt;100+&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026-05-22&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BehiSecc/awesome-claude-skills&lt;/td&gt;
&lt;td&gt;10K&lt;/td&gt;
&lt;td&gt;100+&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026-06-03&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;heilcheng/awesome-agent-skills&lt;/td&gt;
&lt;td&gt;6K&lt;/td&gt;
&lt;td&gt;100+&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2026-04-05&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SecretiveShell/Awesome-llms-txt&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;td&gt;81&lt;/td&gt;
&lt;td&gt;2026-09-28&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;karanb192/awesome-claude-skills&lt;/td&gt;
&lt;td&gt;544&lt;/td&gt;
&lt;td&gt;100+&lt;/td&gt;
&lt;td&gt;2026-10-02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;thedaviddias/llms-txt-hub&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;100+&lt;/td&gt;
&lt;td&gt;2026-09-25&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;100+&lt;/code&gt; is the API's page limit. The real numbers are higher.&lt;/p&gt;

&lt;p&gt;The three biggest targets — the ones that would have been worth the most — &lt;strong&gt;haven't merged a single PR in four to six months.&lt;/strong&gt; They were flooded with submissions (largely AI-generated, which is its own irony here) and the maintainers stopped showing up.&lt;/p&gt;

&lt;p&gt;My PRs are sitting at position ~100 in a queue that isn't being processed. They will never merge. And I spent a week of effort producing them, because I never checked whether the channel was alive before I committed to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That's the most expensive lesson in this post:&lt;/strong&gt; validate that a distribution channel is &lt;em&gt;actually functioning&lt;/em&gt; before you spend a week in it. "This repo has 76K stars" says nothing about whether it accepts contributions in 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 5: What did rank — and the one pattern worth keeping
&lt;/h2&gt;

&lt;p&gt;Seven pages got impressions. Of those, three landed on page one:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Position&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/meme-generator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/inventory-spreadsheet-generator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/subnet-calculator&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And by query, the standout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;validate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;json&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;position&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;ukuran&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;tiktok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pixel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;position&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;validar&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;json&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;position&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;validator&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="err"&gt;→&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;position&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two observations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A single page can rank fast — for a query the big sites didn't bother with.&lt;/strong&gt; &lt;code&gt;/json-validator&lt;/code&gt; hit position 1 for &lt;code&gt;validate json&lt;/code&gt;. That's a page competing with established developer-tool sites, and it won on specificity. &lt;code&gt;validar json&lt;/code&gt; at 17 suggests the Spanish pages have the same opening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;But impressions were single digits.&lt;/strong&gt; Ranking #1 for a query that gets searched a few times a week produces three impressions and zero clicks. Low-difficulty and low-volume are the same property. A brand-new domain can only win the queries nobody else wants, and those queries are small.&lt;/p&gt;

&lt;p&gt;Which is not an argument against the approach — it's an argument against expecting it to be fast. The compounding is real, it's just slow, and it starts from zero.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Stopped shipping pages.&lt;/strong&gt; The marginal page was worth approximately nothing — 302 pages produced 29 impressions. Page 303 would produce a fraction of an impression. Worse, an ever-growing backlog of unindexed URLs against a fixed crawl allowance means each page gets a smaller share.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirected the effort into links from channels that actually work.&lt;/strong&gt; Not GitHub READMEs with 100-deep PR queues — directories, communities, and content platforms where submissions get processed. The metric is links that &lt;em&gt;land&lt;/em&gt;, not links that are &lt;em&gt;attempted&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kept the pages that already ranked, and left them alone.&lt;/strong&gt; Three pages at positions 6–7 are the only proven asset. The instinct is to go rewrite everything; the discipline is to not disturb what's working.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest timeline
&lt;/h2&gt;

&lt;p&gt;Nothing about this is fast. A new domain with no backlink history:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weeks 1–2:&lt;/strong&gt; crawling, almost no indexing, single-digit impressions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weeks 3–8:&lt;/strong&gt; indexation accelerates &lt;em&gt;if&lt;/em&gt; authority signals appear; first real impressions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Months 2–4:&lt;/strong&gt; rankings begin to stabilize and traffic becomes measurable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anyone promising faster on a fresh domain is selling something. The pSEO playbook works — but it's a &lt;em&gt;compounding&lt;/em&gt; strategy, and compounding has a floor you have to survive first.&lt;/p&gt;

&lt;p&gt;The floor is a week of building followed by several weeks of near-zero feedback. I was not prepared for how demoralizing that stretch is, which is exactly why I'm writing the numbers down instead of only publishing the eventual graph.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Raw data: Search Console, property created 2026-10-08. The site is &lt;a href="https://toolpantry.app" rel="noopener noreferrer"&gt;toolpantry.app&lt;/a&gt;. Happy to share the specific queries or the PR list if anyone's planning a similar play and wants to skip a mistake.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>marketing</category>
      <category>startup</category>
    </item>
    <item>
      <title>208 file tools that never upload your files — and the 7 bugs that taught us how</title>
      <dc:creator>shenghua wu</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:21:37 +0000</pubDate>
      <link>https://dev.to/sebestian/208-file-tools-that-never-upload-your-files-and-the-7-bugs-that-taught-us-how-208p</link>
      <guid>https://dev.to/sebestian/208-file-tools-that-never-upload-your-files-and-the-7-bugs-that-taught-us-how-208p</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcisrp9t3lxk2k5b34n8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzcisrp9t3lxk2k5b34n8.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; I build Tool Pantry with AI coding agents, and this article was drafted with AI assistance from my own engineering notes and test logs. Every technical claim below — file sizes, byte counts, pixel diffs, MD5s — was verified against the source code or real test output, and I've marked the two places where I had to correct an earlier conclusion.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every "free online PDF tool" works the same way: you pick a file, it uploads to their server, they process it, you download the result. That's fine until you're handling a passport scan, a signed contract, or a medical record.&lt;/p&gt;

&lt;p&gt;We built &lt;a href="https://toolpantry.app" rel="noopener noreferrer"&gt;Tool Pantry&lt;/a&gt; on the opposite premise: &lt;strong&gt;208 tools, zero uploads&lt;/strong&gt;. PDF, images, video, audio, text. Nothing ever leaves the device — not by policy, by architecture.&lt;/p&gt;

&lt;p&gt;That constraint turned out to be a much better engineering problem than we expected. Here are the seven things that actually bit us.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The 25 MiB wall — why we abandoned ffmpeg.wasm
&lt;/h2&gt;

&lt;p&gt;Video compression was the first "obviously we'll use the obvious library" decision. &lt;code&gt;ffmpeg.wasm&lt;/code&gt; is the standard answer for browser video.&lt;/p&gt;

&lt;p&gt;It died at deploy time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ffmpeg-core.wasm&lt;/code&gt; is &lt;strong&gt;30.7 MiB&lt;/strong&gt;. Cloudflare Workers caps a single asset at &lt;strong&gt;25 MiB&lt;/strong&gt;. Not a soft limit — the deploy just fails. We could have moved off Workers to keep the library, but shipping a 30 MB download to every visitor is its own problem.&lt;/p&gt;

&lt;p&gt;So we rebuilt the pipeline on native browser primitives:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Demux&lt;/td&gt;
&lt;td&gt;MP4Box.js&lt;/td&gt;
&lt;td&gt;~200 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decode / scale / encode&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;WebCodecs&lt;/strong&gt; (&lt;code&gt;VideoDecoder&lt;/code&gt;, &lt;code&gt;VideoEncoder&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mux&lt;/td&gt;
&lt;td&gt;mp4-muxer&lt;/td&gt;
&lt;td&gt;~50 KB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Total: &lt;strong&gt;~390 KB&lt;/strong&gt; instead of ~30 MB — a 77× reduction. And it's not just smaller, it's &lt;em&gt;faster&lt;/em&gt;, because WebCodecs hits hardware encoders instead of compiling x264 to WASM.&lt;/p&gt;

&lt;p&gt;Verified output: 1280×720 6s source → 854×480 H.264 Baseline + AAC, 1.81 MB, confirmed with &lt;code&gt;ffprobe&lt;/code&gt; picking apart the actual container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; the ecosystem's obvious library was written when the platform couldn't do the job. Check whether the platform caught up before you ship the workaround.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. &lt;code&gt;pdf-lib&lt;/code&gt;'s &lt;code&gt;removePage&lt;/code&gt; doesn't delete anything
&lt;/h2&gt;

&lt;p&gt;Deleting a page from a PDF should make the file smaller. Ours got &lt;em&gt;bigger&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;793 KB  →  remove one page  →  812 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;removePage()&lt;/code&gt; unlinks the page from the page tree, but the page's content stream, images, and fonts stay in the file — reachable through the incremental-update structure. The bytes are still there. Forensically, the "deleted" page is still in your document.&lt;/p&gt;

&lt;p&gt;The fix is a rebuild: create a fresh &lt;code&gt;PDFDocument&lt;/code&gt;, &lt;code&gt;copyPages()&lt;/code&gt; only the pages you're keeping, and save that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;812 KB  →  copyPages rebuild  →  510 KB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 37% reduction — the data genuinely gone.&lt;/p&gt;

&lt;p&gt;The side effect is worth disclosing to users: rebuilding a PDF drops its metadata (author, producer, creation dates). We say so on the page rather than letting people discover it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; in PDF, "delete" almost always means "unlink." If a user's threat model includes recovering deleted content, unlinking is not enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. &lt;code&gt;constructor.name&lt;/code&gt; is a lie in minified builds
&lt;/h2&gt;

&lt;p&gt;We shipped a PDF form-filler that detects AcroForm fields — text, checkbox, dropdown, radio — and renders the right input for each.&lt;/p&gt;

&lt;p&gt;It worked perfectly in development and detected nothing in production.&lt;/p&gt;

&lt;p&gt;Root cause: pdf-lib ships minified. In the minified build, &lt;strong&gt;every class's &lt;code&gt;constructor.name&lt;/code&gt; is &lt;code&gt;"n"&lt;/code&gt;&lt;/strong&gt;. Our type dispatch (&lt;code&gt;field.constructor.name === 'PDFTextField'&lt;/code&gt;) matched nothing.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;instanceof&lt;/code&gt; against the exported classes, which survives minification because it compares actual prototype chains:&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;var&lt;/span&gt; &lt;span class="nx"&gt;isText&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFTextField&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;isCheck&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFCheckBox&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;isDropdown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFDropdown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;isOptionList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFOptionList&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;isRadio&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;field&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;PDFLib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PDFRadioGroup&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; never dispatch on &lt;code&gt;constructor.name&lt;/code&gt; for anything that ships minified — including your own code, if a bundler might rename it. &lt;code&gt;instanceof&lt;/code&gt; or an explicit discriminant field, always.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. MP4 rotation lives in the header, not the pixels
&lt;/h2&gt;

&lt;p&gt;A phone video shot in portrait is usually stored &lt;strong&gt;landscape&lt;/strong&gt;, with a rotation matrix in the &lt;code&gt;tkhd&lt;/code&gt; box telling the player "turn this 90°." The pixels never change; the metadata does.&lt;/p&gt;

&lt;p&gt;MP4Box.js exposes both, and they disagree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;track.track_width&lt;/code&gt; / &lt;code&gt;track_height&lt;/code&gt; → the &lt;strong&gt;display&lt;/strong&gt; size from &lt;code&gt;tkhd&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;videoTrack.video.width&lt;/code&gt; / &lt;code&gt;height&lt;/code&gt; → the &lt;strong&gt;encoded&lt;/strong&gt; size from &lt;code&gt;stsd&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We used the first one everywhere. Every operation on a rotated video was wrong — crops landed in the wrong place, blends misaligned, output dimensions swapped.&lt;/p&gt;

&lt;p&gt;Once we understood the split, a better option appeared for the operations that don't need pixel access. Rotating a video isn't decoding 30 million pixels and re-encoding them — it's &lt;strong&gt;rewriting one 3×3 matrix in a header&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We rewrite the &lt;code&gt;tkhd&lt;/code&gt; matrix directly. Verified: byte-identical result to re-encoding with &lt;code&gt;transpose=1&lt;/code&gt;, pixel difference &lt;strong&gt;0.0&lt;/strong&gt;. It finishes in 0.0 seconds on a 6-second clip, because nothing is decoded.&lt;/p&gt;

&lt;p&gt;Same trick for muting: stream-copy the video track, drop the audio track. The output video bitstream's MD5 is &lt;strong&gt;identical to the source&lt;/strong&gt;. Lossless, verifiable, instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; before you write the pixel loop, check whether the answer is already a number in a header.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. The color bug that wasn't — verify your measurement tool first
&lt;/h2&gt;

&lt;p&gt;This one nearly cost us a working pipeline.&lt;/p&gt;

&lt;p&gt;We compared input and output video frames pixel by pixel and got a clean, damning result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output ≈ 0.81 × source + 23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That formula is the signature of a YUV↔RGB &lt;strong&gt;range mismatch&lt;/strong&gt; — the classic full-range (0–255) vs. limited-range (16–235) bug. Textbook. So we started hunting for it: checked the VUI and &lt;code&gt;colr&lt;/code&gt; atoms, inspected our &lt;code&gt;VideoFrame&lt;/code&gt; color-space options, ran encode round-trips, scanned for time-alignment offsets, generated difference maps.&lt;/p&gt;

&lt;p&gt;We found nothing, because there was nothing to find.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ffprobe&lt;/code&gt; was reading our &lt;strong&gt;untagged&lt;/strong&gt; source file and &lt;em&gt;guessing&lt;/em&gt; the YUV matrix. It guessed wrong. The "0.81× + 23" was an artifact of the measuring tool, not a defect in our code.&lt;/p&gt;

&lt;p&gt;The real test: decode source and output in the same decoder (Chrome) and compare flat regions — delta &lt;strong&gt;≤ 3&lt;/strong&gt;, which is just normal lossy-compression noise.&lt;/p&gt;

&lt;p&gt;We were one commit away from "fixing" a correct color pipeline and breaking it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; when a measurement gives you a textbook-perfect diagnosis, be suspicious. Confirm the &lt;em&gt;tool&lt;/em&gt; before you trust the &lt;em&gt;verdict&lt;/em&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. &lt;code&gt;SHA256&lt;/code&gt; is not &lt;code&gt;SHA-256&lt;/code&gt;
&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;await&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// → NotSupportedError&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WebCrypto wants the hyphenated form: &lt;code&gt;'SHA-256'&lt;/code&gt;. The un-hyphenated spelling fails with an error that gives you no hint about the format being the problem.&lt;/p&gt;

&lt;p&gt;Our hash tool now maps friendly names to the exact WebCrypto identifiers:&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;webcryptoNames&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sha384&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-384&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sha512&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SHA-512&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We checked the output byte-for-byte against Python's &lt;code&gt;hashlib&lt;/code&gt; to be sure. It matches.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Transparent canvas pixels have garbage color channels
&lt;/h2&gt;

&lt;p&gt;The signature tool has a mode that turns a photo of a signature on paper into a clean transparent PNG: threshold away the paper, keep the ink, recolor, auto-crop.&lt;/p&gt;

&lt;p&gt;Bug: it kept hallucinating ink in empty areas.&lt;/p&gt;

&lt;p&gt;Cause: in a canvas, a fully transparent pixel can still have color values — and drawing operations often leave them at &lt;code&gt;0,0,0&lt;/code&gt; (black). Our threshold ("dark pixels = ink") read the entire transparent region as ink.&lt;/p&gt;

&lt;p&gt;Fix: gate on alpha &lt;em&gt;before&lt;/em&gt; thresholding.&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;alpha&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// never trust the color of an invisible pixel&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; transparent pixels are not "empty." Their color channels are whatever the last write left behind. Always check alpha first.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this actually costs you (the honest section)
&lt;/h2&gt;

&lt;p&gt;Client-side isn't free. The real trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Download size.&lt;/strong&gt; Background removal needs a U²-Net model via ONNX Runtime Web — about &lt;strong&gt;15 MB&lt;/strong&gt;, lazy-loaded. OCR needs Tesseract WASM — about &lt;strong&gt;8 MB&lt;/strong&gt;. First use of those two tools is a real wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Browser floors.&lt;/strong&gt; The video tools need &lt;code&gt;WebCodecs&lt;/code&gt; — Chrome/Edge, and Safari 17+. Older browsers can have the PDF and image tools, not the video ones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threading requires headers.&lt;/strong&gt; ONNX Runtime's multithreaded path needs &lt;code&gt;COOP: same-origin&lt;/code&gt; + &lt;code&gt;COEP: require-corp&lt;/code&gt;. That's fine for a site with no third-party embeds, and it means those two headers are now load-bearing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And there's a ceiling.&lt;/strong&gt; Anything that genuinely needs a large model or a server-side API — LLM summarization, translation, image generation — we don't build. We say so instead of shipping a fake version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But the floor is much higher than it was two years ago, and WebCodecs is the reason. Video processing in the browser went from "compile ffmpeg to WASM" to "call the platform."&lt;/p&gt;




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

&lt;p&gt;Everything is permissively licensed and self-hosted — no CDN dependencies, no third-party calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Video/audio:&lt;/strong&gt; WebCodecs · MP4Box.js · mp4-muxer · lamejs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF:&lt;/strong&gt; pdf-lib · pdf.js&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images:&lt;/strong&gt; native canvas · heic2any&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ML:&lt;/strong&gt; ONNX Runtime Web (U²-Net) · Tesseract.js&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing is 208 tools with no build step, no backend, and no accounts. If you want to poke at the implementation, it's at &lt;a href="https://toolpantry.app" rel="noopener noreferrer"&gt;toolpantry.app&lt;/a&gt; — and the raw measurements behind the numbers above are written up with methodology at &lt;a href="https://toolpantry.app/compression-benchmarks" rel="noopener noreferrer"&gt;compression-benchmarks&lt;/a&gt; and &lt;a href="https://toolpantry.app/local-processing-benchmarks" rel="noopener noreferrer"&gt;local-processing-benchmarks&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Which of these have you hit? The &lt;code&gt;constructor.name&lt;/code&gt; one feels like it should be common knowledge and somehow isn't. And I'd genuinely like to know if anyone has a case where &lt;code&gt;ffmpeg.wasm&lt;/code&gt; was still the right call — I couldn't find one that fit inside a 25 MiB limit, but that's a deployment constraint, not a general truth.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>webcodecs</category>
      <category>privacy</category>
    </item>
  </channel>
</rss>
