<?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: tomcate</title>
    <description>The latest articles on DEV Community by tomcate (@tomcate_9c4562b60a3ac8fe4).</description>
    <link>https://dev.to/tomcate_9c4562b60a3ac8fe4</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%2F4125662%2Fc5cb08c8-f25c-4fd1-ac27-dd588b93bb32.jpg</url>
      <title>DEV Community: tomcate</title>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tomcate_9c4562b60a3ac8fe4"/>
    <language>en</language>
    <item>
      <title>I Published 24 Articles in 5 Days. Here's What Actually Moved — and What Took 3 Days to Show Up.</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Sat, 10 Oct 2026 01:20:29 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-published-24-articles-in-5-days-heres-what-actually-moved-and-what-took-3-days-to-show-up-3h1a</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-published-24-articles-in-5-days-heres-what-actually-moved-and-what-took-3-days-to-show-up-3h1a</guid>
      <description>&lt;p&gt;title: "I Published 24 Articles in 5 Days. Here's What Actually Moved — and What Took 3 Days to Show Up."&lt;br&gt;
published: true&lt;br&gt;
tags: seo, contentstrategy, webdev, indiehackers&lt;/p&gt;

&lt;h2&gt;
  
  
  description: "A solo dev's content sprint on a 172-tool site: 24 posts in 5 days across two languages, a 38-position jump in Google rankings, a traffic record — and the honest numbers on what lagged (GSC's 2-3 day delay, Baidu's 10-URL daily quota, and the silent MDX build failure that ate a post)."
&lt;/h2&gt;

&lt;p&gt;Two months ago my side project — &lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article8" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt;, 172 browser-local developer tools — had the classic tool-site problem: utility traffic, no depth. People came, used the formatter, left. Google had noticed the same thing: our average position was &lt;strong&gt;52.7&lt;/strong&gt;. Page three of the search results, which is the same as not existing.&lt;/p&gt;

&lt;p&gt;So I ran a content sprint. Not the "write every day" kind — the engineering kind, with a backlog, a bilingual pairing constraint, a per-day URL quota on the biggest search engine in my market, and a build pipeline that fights back. This post is the honest numbers: what moved immediately, what took three days to register, and what quietly broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the sprint
&lt;/h2&gt;

&lt;p&gt;Five days, 24 articles, two languages each — 48 files. Three clusters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Post-quantum cryptography&lt;/strong&gt; (14 posts): migration checklists, ML-KEM internals, passkeys, Ed25519, HKDF — a deliberate bet on a topic where 2026-2028 regulatory timelines are creating search demand faster than supply&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;China compliance&lt;/strong&gt; (6 posts): MLPS 2.0, PIPL impact assessments, generative-AI filing checklists — dense, checklist-heavy, almost no competition from real tool sites&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GEO&lt;/strong&gt; (6 posts): making sites legible to AI search, robots.txt decisions for AI crawlers, llms.txt&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every post follows the same template: a real problem, a working checklist or table, an implementer's note with details only the person who built the thing knows, and cross-links into the tool pages.&lt;/p&gt;

&lt;h2&gt;
  
  
  What moved immediately
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Direct traffic to fresh content.&lt;/strong&gt; The PIPL impact-assessment post hit the site's top-10 pages &lt;em&gt;the day it shipped&lt;/em&gt; — 18 visits from nowhere, which told me the search demand was real and the internal linking was doing its job before Google even noticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The spider economy.&lt;/strong&gt; Baidu's crawler went from ~215 to ~280 hits/day across the sprint — new content doesn't just wait for organic discovery when you're pushing URLs through their API. Speaking of which:&lt;/p&gt;

&lt;h2&gt;
  
  
  The quota war nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Baidu's URL submission API allows &lt;strong&gt;10 URLs per day&lt;/strong&gt; for a site our size. Ten. We had 20 new posts' worth of Chinese URLs on day one of the sprint. That constraint reshaped the whole distribution plan: explicit URL lists over sitemap-driven auto-push (which had previously burned entire days' quotas on alphabetically-first tool pages), a next-morning ritual of pushing the highest-value ten, and English URLs excluded entirely (they go to IndexNow/Bing instead — different ecosystem).&lt;/p&gt;

&lt;p&gt;If your market includes China, budget the quota into your content calendar. It's a strange but real rate limit on your distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  What took three days to show up
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Google Search Console operates on a 2-3 day lag.&lt;/strong&gt; The sprint started on a Friday; the first GSC evidence of the 146 deepened pages (an earlier content pass, same sprint family) arrived Monday. And it arrived spectacularly: average position &lt;strong&gt;52.7 → 14.1&lt;/strong&gt; in a week, with the page-level impressions list composed entirely of deepened posts.&lt;/p&gt;

&lt;p&gt;The lesson that took me an embarrassing amount of time to internalize: &lt;strong&gt;you cannot evaluate a content batch on day one.&lt;/strong&gt; Not on traffic (internal links only carry you so far), not on GSC (the lag). The earliest honest read is day three, and the real read is day seven. Plan your metrics review cadence around that, or you'll either panic or celebrate early, both wrongly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What quietly broke
&lt;/h2&gt;

&lt;p&gt;The scariest part of the sprint wasn't the writing. It was this: &lt;strong&gt;one post silently vanished from the production build.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cause: MDX parses &lt;code&gt;&amp;lt;&lt;/code&gt; followed by a letter or digit as JSX. A perfectly innocent sentence — "lifetimes under 10 years are mostly fine" written with a literal &lt;code&gt;&amp;lt;10&lt;/code&gt; — threw a compile error &lt;em&gt;that the build logged without failing the build&lt;/em&gt;, and the post simply didn't exist at its URL. A 404 where content should be.&lt;/p&gt;

&lt;p&gt;We caught it only because the deployment checklist requires per-post &lt;code&gt;curl&lt;/code&gt; checks: HTTP status plus a feature phrase grep for every new URL, every deploy. The fix was rewriting the sentence in prose ("under 10 years"). But the meta-lesson stands: &lt;strong&gt;static-site content pipelines fail silently, and "the build passed" is not "the content shipped."&lt;/strong&gt; Verify per artifact, not per build.&lt;/p&gt;

&lt;p&gt;(That same week the build also rejected a post title because the OG-image font subset lacked one Chinese character. Content pipelines are pipelines.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The scoreboard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After 5 days&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Published posts&lt;/td&gt;
&lt;td&gt;147&lt;/td&gt;
&lt;td&gt;171&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GSC average position (7-day)&lt;/td&gt;
&lt;td&gt;52.7&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14.1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human UV (server logs, daily, bot-filtered)&lt;/td&gt;
&lt;td&gt;~550&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,104 (record)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Baidu spider hits/day&lt;/td&gt;
&lt;td&gt;~215&lt;/td&gt;
&lt;td&gt;~280&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PQC post traffic&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;top-5 page within 3 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest caveats: correlation isn't attribution — the sprint rode a holiday traffic peak, some of the position jump is the older deepening batch landing, and one day's record UV proves nothing by itself. But the &lt;em&gt;composition&lt;/em&gt; of the traffic changed in a way raw numbers don't show: search-sourced visits to deep content instead of drive-by tool usage.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the distribution plan with the content plan.&lt;/strong&gt; The Baidu quota and the bilingual pairing constraint (our sitemap generator refuses unpaired zh/en posts — a 404-prevention rule that also once blocked a deploy for an hour) should shape the batch sizes from day one, not be discovered mid-sprint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automate the per-post verification harder.&lt;/strong&gt; Every deploy should assert N new URLs × 200 × feature phrase, mechanically. We do it by script now; it should have been that way before the 404&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the three-day rhythm.&lt;/strong&gt; Publish, verify rendering, wait 72 hours, then look at data. Anything else is vibes&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The sprint posts are live on &lt;a href="https://www.wcytcn.com/blog?utm_source=devto&amp;amp;utm_medium=article8" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; — the PQC cluster starts with a migration checklist, and the compliance series is deeper than anything I've found from a tool site. The &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4"&gt;series&lt;/a&gt; covers how the 100%-local architecture makes all of this a one-person operation.&lt;/p&gt;

&lt;p&gt;And if you're running a tool site with thin content: the deepening math is unglamorous and it works. Position 52 to 14 in a week is not a growth hack. It's table stakes arriving late.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A "Review-Safe" Cookie Banner Blinded 98% of My Analytics for 36 Hours (Postmortem)</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Sat, 03 Oct 2026 01:14:28 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/a-review-safe-cookie-banner-blinded-98-of-my-analytics-for-36-hours-postmortem-34md</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/a-review-safe-cookie-banner-blinded-98-of-my-analytics-for-36-hours-postmortem-34md</guid>
      <description>&lt;p&gt;On September 30 we shipped a cookie consent banner and called it &lt;em&gt;review-safe&lt;/em&gt; in the deploy note. For the next 36 hours, it silently uninstalled our analytics from 98% of our visitors — and the scariest part is how reasonably every individual decision looked along the way.&lt;/p&gt;

&lt;p&gt;This is a postmortem of a self-inflicted outage that never threw an error, never returned a 500, and never fired an alert. The outage was invisible by construction, because the thing it broke was the instrument you would use to notice it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article7" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; is my side project: 172 developer tools (JSON, hashing, JWT, PDF, image work) that run entirely in the browser — no uploads, no accounts, no backend except one audited proxy route. Like every hobby site with real traffic, it monetizes (eventually) with ads, and ad programs want to see a consent flow. So the to-do was legitimate: ship a GDPR-style cookie banner before applying for ad review.&lt;/p&gt;

&lt;p&gt;The implementation was, by every checklist we had at the time, clean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A banner with Accept all / Reject all / Essential-only choices&lt;/li&gt;
&lt;li&gt;Consent state persisted, reject semantics honored, page reloads on change&lt;/li&gt;
&lt;li&gt;AA-contrast UI, no layout shift, no route changes, no page structure changes&lt;/li&gt;
&lt;li&gt;Deploy note: &lt;em&gt;"review-safe — no structural changes"&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And one behavioral decision buried in the middle of it: &lt;strong&gt;analytics and ad scripts are injected only after the visitor clicks "Accept all."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the strictest, most privacy-conservative model — opt-in. If you were grading this change for compliance, you would give it extra credit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silence
&lt;/h2&gt;

&lt;p&gt;Here is the number nobody on the team had internalized: &lt;strong&gt;about 98% of visitors never click any button on a consent banner.&lt;/strong&gt; They scroll past it, or dismiss it mentally, or have trained themselves to ignore anything banner-shaped years ago. This is banner blindness, and it is well-documented in the UX literature — it just never appears in the compliance checklist.&lt;/p&gt;

&lt;p&gt;With opt-in gating, "never clicks" means "never loads the script." Not "loads it later." Not "loads it with reduced granularity." The script simply does not exist for that pageview.&lt;/p&gt;

&lt;p&gt;The next day, GA4 reported &lt;strong&gt;8 users&lt;/strong&gt;. The day before the deploy: ~115.&lt;/p&gt;

&lt;p&gt;And the server access log, which listens to no consent banners, counted &lt;strong&gt;446 unique human visitors&lt;/strong&gt; that same day.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Sept 30 (before)&lt;/th&gt;
&lt;th&gt;Oct 1 (opt-in gate live)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GA4 active users&lt;/td&gt;
&lt;td&gt;~115&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server log, unique humans&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;446&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Analytics coverage didn't dip. It fell off a cliff to ~2%, on the healthiest traffic day of the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it took 36 hours to notice
&lt;/h2&gt;

&lt;p&gt;Day 1, the drop got read as a reporting artifact. GA4 shows a "processing incomplete" badge on recent days, and 8-vs-yesterday is exactly the kind of number a broken report card would explain away. The badge was real — GA4 reports genuinely do lag — and that's what made it such an effective camouflage. A lie is easy to catch; a &lt;em&gt;technically true&lt;/em&gt; status message that stops applying at the exact wrong moment is much harder.&lt;/p&gt;

&lt;p&gt;The deeper failure was structural: &lt;strong&gt;we had exactly one instrument, and the outage broke that instrument.&lt;/strong&gt; A single-channel monitoring setup cannot detect the death of its own channel. It's the monitoring equivalent of the light in the fridge — everything looks fine from inside.&lt;/p&gt;

&lt;p&gt;Day 2 morning, the cross-check finally happened: 446 humans in nginx vs 8 users in GA4 is not a reporting lag, it's a 98% disagreement. From there the diagnosis took minutes, because each step only had one possible next step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;curl&lt;/code&gt; the homepage → no &lt;code&gt;gtag.js&lt;/code&gt; in the HTML&lt;/li&gt;
&lt;li&gt;Search the codebase for what conditionally renders analytics → the new &lt;code&gt;ConsentScripts&lt;/code&gt; component&lt;/li&gt;
&lt;li&gt;Git timeline → shipped 20:03 the night before, labeled &lt;em&gt;review-safe&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Deploy the fix at 09:17. Realtime shows a live visitor within the minute. Total time from first blind pageview to fix: ~36 hours, of which nearly all were "nobody knew."&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: opt-out, and why it's still compliant
&lt;/h2&gt;

&lt;p&gt;The repaired model is opt-out: analytics loads by default; an explicit click on "Reject all" (or "Essential only") stops it, immediately and persistently. Visitors keep full control — the reject path is honored more carefully than before, down to reloading the page so no already-loaded script survives the choice.&lt;/p&gt;

&lt;p&gt;Opt-out-with-real-rejection is the model most of the industry actually runs (and what many regulators effectively expect for analytics-grade, non-personalized usage). Pure opt-in for measurement scripts sounds saintly and, combined with human banner behavior, functions as an analytics kill switch. Compliance is not a slider where "most restrictive" always means "most correct" — it's a trade-off that has to survive contact with how people actually behave.&lt;/p&gt;

&lt;p&gt;One more lesson shipped with the fix, free of charge: our CDN caches HTML with &lt;code&gt;s-maxage=3600, stale-while-revalidate=86400&lt;/code&gt;. That means after the fix deployed, the &lt;em&gt;first&lt;/em&gt; visitor to each cached URL still got the old consent-gated page, and only triggered a refresh for the next one. With 172 tool pages and 140+ blog posts of long-tail traffic, stale copies of the blind page kept serving for up to 24 hours. "Fixed" and "effective" are different timestamps, and the distance between them is your cache TTL × your URL count.&lt;/p&gt;

&lt;h2&gt;
  
  
  The counterfactual that made us take it seriously
&lt;/h2&gt;

&lt;p&gt;Here's the part that turned this from an embarrassing anecdote into a process change. At the time of the incident, the ad program hadn't approved the site yet — &lt;strong&gt;zero ads were running&lt;/strong&gt; — so the revenue impact was exactly ¥0.&lt;/p&gt;

&lt;p&gt;If the same gate had shipped two weeks later, with ads live at ~1,000 visitors/day: 98% coverage loss on ad inventory, at typical RPMs, is roughly &lt;strong&gt;¥9,800/day&lt;/strong&gt; ($1,400). And there is no alert for "ads didn't render" — you'd discover it in the weekly review, 5–7 days in, at ¥50k–70k. A "compliance improvement" that quietly costs a month of revenue.&lt;/p&gt;

&lt;p&gt;Zero loss by luck is not zero risk. The only reason this was free is that we hadn't yet earned the ability to lose money.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four guardrails we now run
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Analytics smoke test after every deploy (mandatory, 60 seconds).&lt;/strong&gt; Open any tool page, open GA4 Realtime, see your own visit within 30 seconds. If you can't, you roll back before touching anything else. This single habit would have caught the outage at 20:05 instead of two days later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-channel validation, daily.&lt;/strong&gt; Analytics users vs. server-log unique humans. Divergence over 50% must be investigated &lt;em&gt;that day&lt;/em&gt;, and "GA4 processing is lagging" is no longer an acceptable closing statement for a 98% gap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any change that touches when scripts load is a revenue-pipe change.&lt;/strong&gt; The deploy note for anything touching consent, analytics, or ad loading must carry one explicit line: &lt;em&gt;who can still be measured, and who can't.&lt;/em&gt; "No structural change" describes the diff, not the behavior.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance changes get a business-impact review, not just a legal one.&lt;/strong&gt; Coverage of measurement, coverage of ad inventory, revenue at risk — written down next to the AA contrast check. Our postmortem's counterfactual math (above) is the template.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;p&gt;The site that survived its own consent banner is &lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article7" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; — 172 developer tools that process everything locally in your browser, which is also why it can run an honest default-on analytics setup: there's no upload pipeline to consent to in the first place.&lt;/p&gt;

&lt;p&gt;The rest of the &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4"&gt;series&lt;/a&gt; covers the 100%-local architecture, auditing the one route that broke that promise, and what six frontier models saw (and missed) when auditing the lying code.&lt;/p&gt;

&lt;p&gt;And the takeaway I'd tattoo on the inside of every deploy note: &lt;strong&gt;if a change gates when scripts load, it doesn't matter that it "only touches compliance" or "only touches UI" — it just changed your revenue pipes. Smoke-test the pipes.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>analytics</category>
      <category>privacy</category>
      <category>postmortem</category>
    </item>
    <item>
      <title>I Turned a Real Security Incident Into a Benchmark. 6 Models Audited the Code That Lied.</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:41:05 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-turned-a-real-security-incident-into-a-benchmark-6-models-audited-the-code-that-lied-192p</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-turned-a-real-security-incident-into-a-benchmark-6-models-audited-the-code-that-lied-192p</guid>
      <description>&lt;p&gt;Three weeks ago I audited a server-side proxy route in my own product and found that its code had been lying to me. The comments confidently described a security posture — validate every target, strip credentials, cap responses — while the implementation quietly leaked past all of it. I fixed the bugs, wrote up the incident, and then realized I was sitting on a better question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a human auditor was initially fooled by self-confident code, would AI models be fooled too?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I turned the incident into a benchmark on Kaggle, ran six frontier models against it, and the results falsified my hypothesis in an interesting way. This post is that experiment — built and submitted for the Kaggle Benchmarking Challenge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark: one real file, two variants
&lt;/h2&gt;

&lt;p&gt;The test subject is &lt;code&gt;proxy.ts&lt;/code&gt;, a real production file from &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article6" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; (my browser-local developer tools site — the one server route it has). It forwards user-directed HTTP requests to third-party targets, which makes it exactly the kind of code where SSRF matters.&lt;/p&gt;

&lt;p&gt;I planted nothing. The file contains &lt;strong&gt;seven real defects&lt;/strong&gt; that a human adversarial audit later confirmed, including two critical SSRFs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Redirect following without re-validating targets (a public domain can 302 the proxy into &lt;code&gt;169.254.169.254&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A DNS-rebinding TOCTOU: the validation does one DNS lookup, the connection does a second&lt;/li&gt;
&lt;li&gt;Rate limiting keyed on the client-forgeable &lt;code&gt;x-real-ip&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;Proxy control headers (&lt;code&gt;x-proxy-insecure: 1&lt;/code&gt;, ...) forwarded to third-party targets&lt;/li&gt;
&lt;li&gt;Unbounded response buffering (post-hoc "truncation")&lt;/li&gt;
&lt;li&gt;A body-limit rejection surfacing as an unhandled 500&lt;/li&gt;
&lt;li&gt;HTTP 310 — a real legacy status code — reused as a synthetic "too many redirects" sentinel&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then I created two variants of the same task on &lt;a href="https://www.kaggle.com/benchmarks/tasks/tomcate/audit-proxy-with-comments/4" rel="noopener noreferrer"&gt;Kaggle Benchmarks&lt;/a&gt;: the code &lt;strong&gt;with its original confident comments&lt;/strong&gt;, and the identical code with &lt;strong&gt;every comment stripped&lt;/strong&gt;. The task prompt asks for a security audit with severity, concrete attack scenario, and minimal fix per finding. A strict judge model grades each audit: a defect counts only if the &lt;em&gt;mechanism&lt;/em&gt; is named — "there might be some SSRF" scores nothing. The score is defects found, minus half a point per hallucinated finding.&lt;/p&gt;

&lt;p&gt;Why the two variants? My hypothesis going in: the confident comments ("strip hop-by-hop headers", "credentials are never forwarded") would anchor the models, and the stripped variant would score &lt;em&gt;higher&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The models
&lt;/h2&gt;

&lt;p&gt;Eight models across six families, flagship through open-weights: Claude Opus 5, Claude Sonnet 4.5, GPT-5.5, GPT-6 Astra, Gemini 3.8 Flash, DeepSeek-R1, GLM-5, and Gemini 3.5 Flash-Lite. (Gemini 3.8 Flash stayed rate-limited by the model proxy through both rounds, so it does not appear in the completed matrix.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 1: the ceiling is four out of seven
&lt;/h2&gt;

&lt;p&gt;The best score of the entire benchmark is &lt;strong&gt;4/7&lt;/strong&gt;, achieved with &lt;em&gt;mechanism-level&lt;/em&gt; credit. The distribution:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;With comments&lt;/th&gt;
&lt;th&gt;Comments stripped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GLM-5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-5.5&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.5&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5&lt;/td&gt;
&lt;td&gt;2.5&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DeepSeek-R1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;3.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash-Lite&lt;/td&gt;
&lt;td&gt;2.0&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Astra&lt;/td&gt;
&lt;td&gt;1.0&lt;/td&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two critical SSRFs were found by most models — genuinely good news. But defect #6 (an error-handling gap) was named by almost nobody with a correct mechanism, and defect #7 was found by &lt;strong&gt;zero models out of eleven completed audits&lt;/strong&gt;. A tiny wrongness — reusing a real HTTP status code as a sentinel — is invisible to every frontier model that looked at it. Humans miss it too, honestly. That is precisely why it survives in production code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 2: mentioning is not finding
&lt;/h2&gt;

&lt;p&gt;The sharpest signal came from comparing what models &lt;em&gt;wrote&lt;/em&gt; against what they were &lt;em&gt;credited for&lt;/em&gt;. GPT-6 Astra's audit touched five of the seven defect areas — redirect risk, control headers, buffering, the error path — and was credited for exactly &lt;strong&gt;1.0&lt;/strong&gt;. GPT-5.5's stripped-variant audit name-checked six areas and earned 2.0.&lt;/p&gt;

&lt;p&gt;This is the gap between a security audit that reads well and one that bites. The models produce beautifully organized reports where each section &lt;em&gt;gestures&lt;/em&gt; at a vulnerability class. The grader — like a real adversarial reviewer — demands the mechanism: &lt;em&gt;which line, which second DNS resolution, which header&lt;/em&gt;. Under that standard, fluency collapses to nothing. If you use LLMs for code review, this is the thing to worry about: not that they refuse to find bugs, but that their findings are frequently gesture without mechanism, and it takes a skeptical reader to notice the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Result 3: my hypothesis was wrong — the comments &lt;em&gt;helped&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The paired results flipped my prediction. Three of four models with completed pairs scored &lt;em&gt;higher&lt;/em&gt; with the comments present than without (mean delta +0.88):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Claude Sonnet 4.5: 4.0 with comments vs 2.0 without&lt;/li&gt;
&lt;li&gt;GPT-5.5: 3.5 vs 2.0&lt;/li&gt;
&lt;li&gt;GLM-5: 4.0 vs 3.5&lt;/li&gt;
&lt;li&gt;GPT-6 Astra: 1.0 vs 1.5 (the one exception)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My best reading: the comments did not blind the models — they oriented them. A comment like "the redirect follows Postman behavior" tells the auditor &lt;em&gt;where the author's intentions live&lt;/em&gt;, and auditing intentions against implementation is exactly where the defects hide. Stripped of comments, the models had less scaffolding for suspicion, not more.&lt;/p&gt;

&lt;p&gt;Honest caveats: four paired models is a small sample, the with-comments variant is also the longer text (the two variables are not fully separated), and one model was dropped entirely for proxy rate limits — infrastructure bias is real in benchmarks, including mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would measure next
&lt;/h2&gt;

&lt;p&gt;Three follow-ups, in order: (1) same variants, but ask models to &lt;em&gt;fix&lt;/em&gt; the code, then diff the fixes against the human patch — audit-accuracy vs repair-accuracy are different skills; (2) a third variant where the comments actively &lt;em&gt;misdescribe&lt;/em&gt; the code (claiming validation that does not exist), which is the stronger version of the lying-code question; (3) more models on the exact same task — the benchmark is public, and every additional model makes the picture less anecdotal.&lt;/p&gt;

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

&lt;p&gt;The benchmark is public on Kaggle — audit the lying proxy yourself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.kaggle.com/benchmarks/tasks/tomcate/audit-proxy-with-comments/4" rel="noopener noreferrer"&gt;Task A: with the original comments&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.kaggle.com/benchmarks/tasks/tomcate/audit-proxy-no-comments/4" rel="noopener noreferrer"&gt;Task B: comments stripped&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The proxy that came out of the human audit — with per-hop re-validation, connecting only to validated IPs, Host/SNI pinned to the original domain — is live in the &lt;a href="https://www.wcytcn.com/tools/api-tester?utm_source=devto&amp;amp;utm_medium=article6" rel="noopener noreferrer"&gt;API tester&lt;/a&gt; on ToolVault. The rest of the &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4"&gt;series&lt;/a&gt; covers the 100%-local architecture behind it.&lt;/p&gt;

&lt;p&gt;And the takeaway I keep re-learning: the scariest code is not the code with no comments. It is the code whose comments sound exactly like your security checklist.&lt;/p&gt;

</description>
      <category>kagglechallenge</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>security</category>
    </item>
    <item>
      <title>My Site Has No Backend — Except One Route. Auditing It Found 4 Real Holes.</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Thu, 24 Sep 2026 10:53:20 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/my-site-has-no-backend-except-one-route-auditing-it-found-4-real-holes-1i78</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/my-site-has-no-backend-except-one-route-auditing-it-found-4-real-holes-1i78</guid>
      <description>&lt;p&gt;&lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article5" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; is 172 developer tools that run entirely in your browser. No backend, no database, no uploads — that is the whole pitch. But there is exactly one server-side route: a proxy that lets the API tester reach endpoints whose CORS policy would block a direct browser call. One day I read its code the way an attacker would. This post is the four holes I found, and the fixes that survived testing against live redirector services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole #1: the redirect that walked past the bouncer
&lt;/h2&gt;

&lt;p&gt;The proxy validated its target before connecting: resolve the host, refuse private and reserved IPs. Solid — except HTTP redirects exist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attacker.com  →  302 Location: http://169.254.169.254/latest/meta-data/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My redirect loop followed &lt;code&gt;Location&lt;/code&gt; and fired the next hop without re-running the validation. One DNS lookup at the door, then the guest list stopped mattering. Any public domain I controlled could pivot the proxy to cloud metadata, localhost services, or the internal network — the classic open-proxy escape.&lt;/p&gt;

&lt;p&gt;The fix is one line of discipline, applied per hop: every redirect target goes through the same validation as the first. Not "validated once at entry" — validated at every hop, because every hop is a new request to a new host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole #2: two DNS lookups means you checked a different host than you connected to
&lt;/h2&gt;

&lt;p&gt;The deeper version doesn't even need a redirect. The code did this:&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;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="c1"&gt;// check: is any record private?&lt;/span&gt;
&lt;span class="c1"&gt;// ...later...&lt;/span&gt;
&lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// Node resolves host AGAIN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two independent resolutions. An attacker controlling the authoritative DNS answers the check with a public IP, then answers the real connection with &lt;code&gt;127.0.0.1&lt;/code&gt;. The validation passed; the connection went somewhere else. This is DNS rebinding, and any check-then-connect pattern written as two lookups is vulnerable to it.&lt;/p&gt;

&lt;p&gt;The fix that kills the whole class: &lt;strong&gt;validate what you connect to, then connect to what you validated.&lt;/strong&gt;&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;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;resolveAndValidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// one lookup, checked&lt;/span&gt;
&lt;span class="nx"&gt;https&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                             &lt;span class="c1"&gt;// connect to the validated IP&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;  &lt;span class="c1"&gt;// Host header stays the domain&lt;/span&gt;
  &lt;span class="na"&gt;servername&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                 &lt;span class="c1"&gt;// TLS SNI stays the domain&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No second resolution exists, so there is no gap to race. The Host header and SNI still carry the original domain, so virtual-host routing and certificate validation behave exactly as before — verified against a public echo service that reported &lt;code&gt;host: postman-echo.com&lt;/code&gt; while the socket connected to an IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hole #3 and #4: the client-side bugs an audit caught for free
&lt;/h2&gt;

&lt;p&gt;Digging through the surrounding code found two bugs that no test had caught:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;btoa&lt;/code&gt; with a non-Latin1 password crashed the whole page.&lt;/strong&gt; Basic auth preview ran &lt;code&gt;btoa(user + ":" + pass)&lt;/code&gt; during render. A Chinese character in the password field — not exotic for a Chinese-locale site — throws &lt;code&gt;InvalidCharacterError&lt;/code&gt;, and since it executed in the render path, the entire tool went white. The digest-auth path three components over already used the UTF-8-safe pattern; nobody had unified them. One shared &lt;code&gt;toBase64Utf8()&lt;/code&gt; helper closed it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A file upload that silently sent a filename instead of the file.&lt;/strong&gt; The form-data editor updated a row twice in a row — set the &lt;code&gt;File&lt;/code&gt; object, then set the display name. Both updates were computed from the same stale props closure, so the second call rebuilt the row from the pre-file state and erased the first. &lt;code&gt;row.file&lt;/code&gt; was always &lt;code&gt;null&lt;/code&gt; by the time the request was built; the multipart body carried the filename string. No error, no warning — the upload just didn't work, ever.&lt;/p&gt;

&lt;p&gt;Both fixes are one-liners once diagnosed. The lesson isn't the bugs; it's that &lt;strong&gt;they survived 1,246 passing tests&lt;/strong&gt;. Unit tests verify the code you thought to write; an adversarial read asks what the code actually permits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet ones: rate-limit trust and control headers
&lt;/h2&gt;

&lt;p&gt;Two more, cheaper but worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The rate limiter read the client IP from &lt;code&gt;x-real-ip&lt;/code&gt; — a header any client can set. Rotate the header, get a fresh bucket, defeat the limit entirely. Now the header is only trusted when the TCP peer itself is our own infra (nginx on the host, the Docker bridge): a public client cannot spoof its source address, so a private-range peer is trustworthy by physics.&lt;/li&gt;
&lt;li&gt;Every &lt;code&gt;x-proxy-*&lt;/code&gt; configuration header (including the "skip TLS verification" flag) was being forwarded to third-party targets. Your debugging flags leaking to someone else's API server is its own genre of incident. Strip the whole prefix before forwarding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist, compressed
&lt;/h2&gt;

&lt;p&gt;If you run any server-side fetch of user-influenced URLs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate every hop&lt;/strong&gt; — the entry check and the redirect check are the same check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connect to the validated IP&lt;/strong&gt; — one resolution, Host/SNI pinned to the domain. This deletes rebinding instead of racing it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Trust forwarded-for-style headers only from provable infra peers.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip your own control headers&lt;/strong&gt; before they reach the outside world.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stream with a hard cap&lt;/strong&gt; — buffer a response of unbounded size and one hostile URL is an OOM; destroy the upstream socket at the limit and flag truncation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And the meta-lesson: the proxy had comments describing excellent security. Comments are not capabilities. The audit that mattered was reading the code as "what can I make this do", not "what does it say it does" — and then verifying the fixes against a live redirector (&lt;code&gt;https://postman-echo.com/redirect-to?url=http://127.0.0.1/...&lt;/code&gt; now dies with "forbidden address", which is the most satisfying log line I have read all week).&lt;/p&gt;

&lt;p&gt;The proxy that does all of this correctly is part of the &lt;a href="https://www.wcytcn.com/tools/api-tester?utm_source=devto&amp;amp;utm_medium=article5" rel="noopener noreferrer"&gt;API tester&lt;/a&gt; on ToolVault — client-side by default, one hardened route when you need it. The rest of the &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4"&gt;series&lt;/a&gt; covers how the 100%-local architecture works and why AI crawlers love it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>node</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Making a Tool Site Legible to AI Assistants: GEO in Practice</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Tue, 22 Sep 2026 10:38:22 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/making-a-tool-site-legible-to-ai-assistants-geo-in-practice-2mof</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/making-a-tool-site-legible-to-ai-assistants-geo-in-practice-2mof</guid>
      <description>&lt;p&gt;There is a new front door on the web, and it does not have a search box. When someone asks ChatGPT, Perplexity, or Claude for "a tool to convert HEIC photos", the assistant picks from what it can see. If your site is invisible to that process, you are not in the consideration set — regardless of your Google ranking.&lt;/p&gt;

&lt;p&gt;This post is the practical checklist we run on &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article4" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; (166 browser-local developer tools), with the actual crawler numbers from our logs and the mistakes that cost us rework.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the uncomfortable physics: AI crawlers do not run JavaScript
&lt;/h2&gt;

&lt;p&gt;GPTBot, ClaudeBot, PerplexityBot and friends fetch HTML and read. They do not hydrate React, they do not execute your bundle, and they will not wait for your client-side rendering to finish. A JavaScript-only tool page arrives at the model as an empty shell.&lt;/p&gt;

&lt;p&gt;Our site is statically exported (998 pre-rendered pages), which made this a non-issue — but if your content lives behind client rendering, that is item zero on this list. Test it the honest way: &lt;code&gt;curl&lt;/code&gt; your page and look for the actual content in the HTML. Not "view source in DevTools" — the raw bytes a crawler gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  llms.txt: useful convention, fatal if hand-maintained
&lt;/h2&gt;

&lt;p&gt;llms.txt is the emerging convention for telling AI agents what your site offers. Ours covers the tool catalog, the privacy model, and per-category entry points, in both English and Chinese.&lt;/p&gt;

&lt;p&gt;The lesson that cost us a month: &lt;strong&gt;generate it at build time from your single source of truth, never hand-edit it.&lt;/strong&gt; Our first version was hand-written. It rotted in the exact ways hand-maintained files always rot: duplicate section titles after a copy-paste, tool counts drifting from reality ("130+ tools" when we had 144), a Chinese edition that never existed because nobody remembered to add one. An llms.txt that misdescribes your site is worse than none — it confidently teaches models the wrong thing.&lt;/p&gt;

&lt;p&gt;Now a build step regenerates it from the same registry that renders the site. Counts are always exact; the file cannot drift because nobody edits it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  robots.txt: decide, explicitly, who you let in
&lt;/h2&gt;

&lt;p&gt;The default posture many sites discover they have (blocking everything unrecognized) or the opposite (allowing everything silently) are both decisions — just unmade ones. Ours is explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight robot_framework"&gt;&lt;code&gt;User-agent: GPTBot&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Allow:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...for every AI crawler we know about, alongside the standard search bots. This is a values call: we want assistants to be able to recommend local-first tools accurately, because the privacy story is the product. A paywalled-content site might reasonably reach the opposite conclusion. Make the call on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reward loop is real, and measurable
&lt;/h2&gt;

&lt;p&gt;After the crawler allowances went live, our nginx logs filled up. The current steady state: &lt;strong&gt;400-1000 AI crawler requests per day&lt;/strong&gt; — GPTBot, PerplexityBot, ClaudeBot, Amazonbot, PetalBot — systematically walking the catalog. And then the loop closes: referral traffic from &lt;code&gt;perplexity.ai&lt;/code&gt; and &lt;code&gt;chatgpt.com&lt;/code&gt; starts appearing in the same logs. Small numbers yet, but the direction is established: assistants that can read your site will cite it, and their users click through.&lt;/p&gt;

&lt;p&gt;We also push every new page through IndexNow (Bing's protocol, which ChatGPT Search partly sits on). It costs one POST request and removes the discovery delay for the engines that honor it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not matter (for us, so far)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Structured data beyond the basics: we ship standard FAQ and tool schemas, but there is no evidence AI assistants consume JSON-LD preferentially over readable HTML.&lt;/li&gt;
&lt;li&gt;Per-prompt optimization tricks: the content that models quote is the content that answers the question plainly. Our per-tool FAQ sections — written for humans — are what surfaces in AI answers.&lt;/li&gt;
&lt;li&gt;Submitting to "AI search engines" directories: the crawlers found us through standard crawling plus IndexNow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The checklist, condensed
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Raw HTML contains your content (curl test) — client-rendered sites fix this first&lt;/li&gt;
&lt;li&gt;llms.txt generated from the build, never hand-edited&lt;/li&gt;
&lt;li&gt;robots.txt with explicit AI-crawler allowances (a decision, not an accident)&lt;/li&gt;
&lt;li&gt;IndexNow for every new page&lt;/li&gt;
&lt;li&gt;FAQ-shaped content that answers real questions in plain language&lt;/li&gt;
&lt;li&gt;Measure: watch crawler UAs in your access logs, then watch for AI-domain referers&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is hard. All of it is the difference between being describable and being invisible when someone asks an assistant what to use.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Post #4 in the local-first series: &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;#1 why 166 no-upload tools&lt;/a&gt;, &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-inclu"&gt;#2 browser-side AI segmentation&lt;/a&gt;, &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/merging-pdfs-without-a-server-the-bytes-under-a-structural-merge-109f"&gt;#3 merging PDFs without a server&lt;/a&gt;. All tools: &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article4" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Merging PDFs Without a Server: The Bytes Under a Structural Merge</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Mon, 21 Sep 2026 04:48:59 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/merging-pdfs-without-a-server-the-bytes-under-a-structural-merge-109f</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/merging-pdfs-without-a-server-the-bytes-under-a-structural-merge-109f</guid>
      <description>&lt;p&gt;My &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-included-4ioi"&gt;last post&lt;/a&gt; covered running AI in the browser. This one is about the quieter member of the local-first family: PDF processing — merging, splitting, compressing — and what actually happens to the bytes when no server is involved.&lt;/p&gt;

&lt;p&gt;The punchline first: a proper PDF merge does not "read" your documents the way a PDF viewer does. It rearranges serialized objects. Understanding that one sentence explains most of the behavior people find surprising in merge tools — and it shows why the whole operation fits comfortably in a browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  A PDF is a database, not a document
&lt;/h2&gt;

&lt;p&gt;Strip the format mystique away and a PDF file is a miniature object database: a header, a trail of objects (pages, fonts, images, streams), a cross-reference table pointing to each object byte offset, and a trailer. "Opening" a PDF means parsing that xref table, loading the page tree, and resolving references. Rendering is just one thing you can do after parsing.&lt;/p&gt;

&lt;p&gt;Which means the three classic operations decompose like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Merge&lt;/strong&gt; = copy the page objects of file B into file A's object space, remap their references, rebuild one xref table&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split&lt;/strong&gt; = the mirror image: keep a subset of the page tree, carry over only the objects those pages reference&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compress (for scanned PDFs)&lt;/strong&gt; = re-encode each page's embedded image stream (JPEG quality is your dial)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that requires rendering a single pixel. That is why &lt;a href="https://www.wcytcn.com/tools/merge-pdf?utm_source=devto&amp;amp;utm_medium=article3" rel="noopener noreferrer"&gt;our merge tool&lt;/a&gt; describes itself as "structural": pages come out byte-identical, text stays selectable, and a vector chart does not degrade, because it was never rasterized in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  What pdf-lib gives you — and what it makes obvious
&lt;/h2&gt;

&lt;p&gt;In the browser this is all &lt;a href="https://github.com/Hopding/pdf-lib" rel="noopener noreferrer"&gt;pdf-lib&lt;/a&gt;, which is pure JavaScript with zero dependencies on native code or workers. The merge core is almost disappointingly small:&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPageIndices&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;copyPages&lt;/code&gt; is where the object-remapping happens. Two things you learn from living with this API:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Page order is list order.&lt;/strong&gt; There is no implicit sorting, no filename intelligence. If users expect page 10 to land after page 9, your UI has to enforce it — which is why reorder arrows exist and why "list order becomes page order" is the honest description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the tool does not preserve is as informative as what it does.&lt;/strong&gt; Bookmarks that point to page positions shift. Form fields sharing a name across documents can collide — pdf-lib will not deduplicate them for you. Encrypted files need decryption before load or they throw. None of these are bugs in the library; they are the format showing its database nature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compression is where honesty matters
&lt;/h2&gt;

&lt;p&gt;The interesting decision in a browser PDF compressor is what to do when compression makes the file &lt;em&gt;bigger&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Scanned PDFs — one big image per page — compress beautifully: re-encode each page image as JPEG at quality 50-65 and 60-90% of the size evaporates, because the 300 DPI scan carries resolution nobody consumes. Vector PDFs generated by Word or LaTeX are already tiny; re-rendering them as images inflates them.&lt;/p&gt;

&lt;p&gt;So the honest compressor has three behaviors: compress scans aggressively, and when the output grows, say so (&lt;code&gt;+12%&lt;/code&gt; on the button, not a silent download) and point users toward splitting instead. Tools that always claim victory are lying to somebody.&lt;/p&gt;

&lt;p&gt;The browser implementation detail that surprised me: canvas-mediated JPEG re-encoding of page images is where the quality dial actually lives, and quality-to-size is emphatically not linear — the useful cliff for scanned documents sits between 50 and 65, where halving the quality costs you single-digit percent of visual fidelity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The privacy argument, restated for files people actually have
&lt;/h2&gt;

&lt;p&gt;For portrait photos, local processing is a nice property. For PDFs it is closer to the whole point, because the documents that trigger PDF operations are disproportionately the sensitive ones — contracts under negotiation, visa applications, bank statements. The flow "I need to merge two files" should not have a mandatory step of "upload my signed contract to a random web service."&lt;/p&gt;

&lt;p&gt;The verification is the same as always: DevTools, Network panel, watch zero requests leave while you work. Our &lt;a href="https://www.wcytcn.com/en/blog/pdf-tools-complete-guide?utm_source=devto&amp;amp;utm_medium=article3" rel="noopener noreferrer"&gt;complete guide to the PDF toolkit&lt;/a&gt; goes deeper on when to merge vs split vs compress — with the same no-upload guarantee.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is hard in the browser (an honest list)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted PDFs&lt;/strong&gt;: pdf-lib supports loading some variants but not decrypting all of them; the correct UX is a clear error, not a partial file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR&lt;/strong&gt;: converting scanned pages back to selectable text needs a heavy model; we shipped without it rather than fake it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Very large merges&lt;/strong&gt;: a few hundred pages works; ten thousand stresses mobile Safari memory — structural merging is cheap, but the file still has to exist in RAM once&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are server-shaped problems pretending to be local. They are the honest boundaries of the approach, and knowing them is the difference between a local tool and a local toy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is post #3 in the local-first series: &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;#1 why we built 166 no-upload tools&lt;/a&gt;, &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-included-4ioi"&gt;#2 browser-side AI segmentation&lt;/a&gt;. All 166 tools live at &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article3" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Running AI Portrait Segmentation Entirely in the Browser — Bugs Included</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Sun, 20 Sep 2026 04:06:40 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-included-4ioi</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/running-ai-portrait-segmentation-entirely-in-the-browser-bugs-included-4ioi</guid>
      <description>&lt;p&gt;My &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;first post here&lt;/a&gt; mentioned that our ID-photo tools run portrait segmentation locally — a passport photo never leaves your machine. A few readers asked how that actually works, so here is the deep dive, including the three bugs that nearly shipped to production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack is deliberately boring
&lt;/h2&gt;

&lt;p&gt;MediaPipe Selfie Segmentation, compiled to WASM, loading a &lt;strong&gt;244KB&lt;/strong&gt; tflite model from our own origin (no CDN, no Google API key, works offline once the PWA is installed). The pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;File → createImageBitmap → &amp;lt;canvas&amp;gt; → segmenter.segment(canvas)
     → categoryMask (Uint8Array) → custom mask pass → composite → download
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything after the file picker is client-side JavaScript. The "server" is nginx serving static files — there is nothing to send data &lt;em&gt;to&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: The mask was inverted, and the specs were no help
&lt;/h2&gt;

&lt;p&gt;The segmentation call returns a &lt;code&gt;categoryMask&lt;/code&gt;. Intuition says: non-zero pixels = person, zero = background. &lt;strong&gt;MediaPipe's convention is the opposite: 0 is the person.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We shipped the intuitive version. The result: users uploaded a portrait, picked "blue background", and got a photo where the &lt;em&gt;person&lt;/em&gt; was painted solid blue and the background stayed untouched. The bug report was a screenshot of a smurf. &lt;/p&gt;

&lt;p&gt;The fix is one line — invert the predicate — but the lesson stuck: when a library hands you an opaque byte array, write a 10-line visualization dump before building anything on top of it. We now render the raw mask as grayscale before every mask-processing change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: The 1-pixel white halo that took three rounds to kill
&lt;/h2&gt;

&lt;p&gt;After recoloring, every hair edge showed a white fringe. Classic cause: the mask boundary is a hard 0/1 cut, but anti-aliased pixels at the boundary are half-person, half-background.&lt;/p&gt;

&lt;p&gt;Round 1: dilate the person region by 1px. Better, halo narrower.&lt;br&gt;
Round 2: 2px. Better still, but now ears looked slightly "eaten".&lt;br&gt;
Round 3 — the actual fix was two changes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dilate the background side by 3px&lt;/strong&gt; (paint background color slightly &lt;em&gt;into&lt;/em&gt; the person), and&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remove the feathering entirely.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That second one is counter-intuitive. We had added edge feathering (alpha-blend the mask boundary) for softness — but feathering blends the new background color at reduced opacity, which is exactly what a translucent white-ish fringe is. Hard edges beat pretty edges here; hair detail survives better with a slightly aggressive dilate than with a soft, diluted boundary.&lt;/p&gt;
&lt;h2&gt;
  
  
  Bug #3: Safari and &lt;code&gt;createImageBitmap&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;createImageBitmap(file)&lt;/code&gt; — the clean, efficient entry point — is unavailable in older Safari. The fallback is an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element with &lt;code&gt;objectURL&lt;/code&gt;, awaited through &lt;code&gt;img.decode()&lt;/code&gt;:&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;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;createImageBitmap&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rej&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onerror&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rej&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both paths end in the same &lt;code&gt;canvas.drawImage&lt;/code&gt;, so downstream code never knows the difference. Feature-detect at the edge, normalize immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "local AI" buys you in practice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A passport photo with face, ID number, and biometric pattern never touches a server — not because of a policy, but because &lt;strong&gt;there is no upload code path at all&lt;/strong&gt;. F12 → Network proves it live.&lt;/li&gt;
&lt;li&gt;It works on a plane, behind the great firewall, and on a locked-down corp machine.&lt;/li&gt;
&lt;li&gt;Model inference at ~100-300ms per photo on a mid-range phone. Users cannot tell it is not a server call.&lt;/li&gt;
&lt;li&gt;Our "server" cost for this feature is 244KB of static bandwidth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this requires a special product. It requires rejecting the default architecture where "AI feature" means "API call".&lt;/p&gt;

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

&lt;p&gt;The ID-photo background changer is live (pick any color, standard sizes for Chinese/US/visa formats, print layouts):&lt;br&gt;
&lt;a href="https://www.wcytcn.com/tools/id-photo-background?utm_source=devto&amp;amp;utm_medium=article2" rel="noopener noreferrer"&gt;ToolVault ID Photo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are building client-side ML: what's the weirdest silent failure you have debugged in a byte array? That mask inversion took us a user screenshot to find — I am curious what yours took.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;More local-first tools: &lt;a href="https://www.wcytcn.com?utm_source=devto&amp;amp;utm_medium=article2" rel="noopener noreferrer"&gt;166 tools, zero uploads&lt;/a&gt;. First post in this series: &lt;a href="https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7"&gt;why we built it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Built 166 Developer Tools That Never Send Your Files to a Server</title>
      <dc:creator>tomcate</dc:creator>
      <pubDate>Tue, 15 Sep 2026 06:41:06 +0000</pubDate>
      <link>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7</link>
      <guid>https://dev.to/tomcate_9c4562b60a3ac8fe4/i-built-166-developer-tools-that-never-send-your-files-to-a-server-bd7</guid>
      <description>&lt;p&gt;Every time I needed to convert a HEIC photo, merge two PDFs, or resize an ID photo, the search results led to the same kind of site: "Upload your file here." A passport photo. A contract PDF. A spreadsheet with real data. All shipped off to some unknown server, processed, and hopefully deleted.&lt;/p&gt;

&lt;p&gt;I wanted the opposite. So I built &lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;ToolVault&lt;/a&gt; — 166 developer and productivity tools where &lt;strong&gt;every computation runs in your browser and zero bytes of your input leave your machine&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data conversion&lt;/strong&gt;: JSON ↔ YAML ↔ XML ↔ CSV ↔ Excel, Excel → Word, Markdown → Docx&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image tools&lt;/strong&gt;: HEIC/WebP/AVIF/SVG conversions, batch resize, images → PDF&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PDF suite&lt;/strong&gt;: merge, split, PDF → JPG, compress — all via pdf-lib, all client-side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ID photo tools&lt;/strong&gt;: background replacement, standard sizes, print layouts. Portrait segmentation runs a 244KB MediaPipe tflite model &lt;strong&gt;in the browser&lt;/strong&gt; — your photo never touches a server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev staples&lt;/strong&gt;: JWT decoder, regex tester, Cron parser, CIDR calculator, hashing, AES/RSA encryption, diff, code formatters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workflows&lt;/strong&gt;: chain multiple tools into a pipeline, so you stop copy-pasting between tabs&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The architecture is the privacy policy
&lt;/h2&gt;

&lt;p&gt;Most "online tools" are thin clients over a backend conversion service. ToolVault is statically exported Next.js — 998 pre-rendered pages served as plain files. There is no backend. No database. Nothing to leak, nothing to breach, nothing to subpoena.&lt;/p&gt;

&lt;p&gt;That sounds like a slogan, so here's how to verify it yourself: open any tool, hit F12, watch the Network panel while you use it. Upload bytes: &lt;strong&gt;0&lt;/strong&gt;. Not "we promise" — observable.&lt;/p&gt;

&lt;p&gt;A few implementation notes that made this work at scale:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Library choices matter.&lt;/strong&gt; Everything runs on battle-tested client-side libraries: pdf-lib, pdf.js, SheetJS, heic2any, jszip, marked. The hard part is composing them (e.g., Safari's &lt;code&gt;createImageBitmap&lt;/code&gt; gaps, which needed an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; decode fallback).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline is a feature, not a gimmick.&lt;/strong&gt; It's an installable PWA. Install it once, disconnect your network, and all 166 tools keep working. "Works on a plane" is the honest privacy demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Segmentation without the cloud.&lt;/strong&gt; The ID-photo background remover runs MediaPipe Selfie Segmentation locally via WASM. The trickiest bug wasn't the model — it was realizing the category mask semantics were inverted from what I assumed (0 = person, not background), and a 3px background dilation needed to kill halo artifacts.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why this matters more than it seems
&lt;/h2&gt;

&lt;p&gt;When a tool uploads your file, you're making a trust decision — about their security, their employees, their acquisition by someone worse. When the computation is local, there is no trust decision. The browser sandbox &lt;em&gt;is&lt;/em&gt; the policy.&lt;/p&gt;

&lt;p&gt;This is also where tool sites are heading: WebAssembly and mature client-side libraries have removed most reasons for a conversion backend to exist. The remaining reason is usually ad-driven engagement ("watch our progress bar while we upload").&lt;/p&gt;

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

&lt;p&gt;👉 &lt;a href="https://www.wcytcn.com/?utm_source=devto&amp;amp;utm_medium=article" rel="noopener noreferrer"&gt;https://www.wcytcn.com&lt;/a&gt; — no signup, no login, no upload. Your bookmarks are the account system.&lt;/p&gt;

&lt;p&gt;If you have a tool you use weekly that doesn't have a "never uploads" version, tell me in the comments — that's my backlog.&lt;/p&gt;

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