<?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: Steven Browning</title>
    <description>The latest articles on DEV Community by Steven Browning (@steven_browning_70ac8fbfa).</description>
    <link>https://dev.to/steven_browning_70ac8fbfa</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%2F4132219%2F63fad681-fa21-4c99-9ba6-52d6f70b9fd0.jpg</url>
      <title>DEV Community: Steven Browning</title>
      <link>https://dev.to/steven_browning_70ac8fbfa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/steven_browning_70ac8fbfa"/>
    <language>en</language>
    <item>
      <title>My FAQ Schema and My FAQ Had Become Two Different Documents</title>
      <dc:creator>Steven Browning</dc:creator>
      <pubDate>Sun, 04 Oct 2026 22:18:09 +0000</pubDate>
      <link>https://dev.to/steven_browning_70ac8fbfa/my-faq-schema-and-my-faq-had-become-two-different-documents-1j92</link>
      <guid>https://dev.to/steven_browning_70ac8fbfa/my-faq-schema-and-my-faq-had-become-two-different-documents-1j92</guid>
      <description>&lt;p&gt;I was rewriting the copy on one of my GPU comparison pages when I noticed something I probably should have checked much earlier.&lt;/p&gt;

&lt;p&gt;The FAQ people could read and the FAQ in the page's structured data were completely different.&lt;/p&gt;

&lt;p&gt;Not slightly different.&lt;/p&gt;

&lt;p&gt;They didn't share a single question.&lt;/p&gt;

&lt;h2&gt;
  
  
  One page, two different FAQs
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;FAQPage&lt;/code&gt; JSON-LD had four questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What matters most for running local LLMs?&lt;/li&gt;
&lt;li&gt;Can I run a local LLM without a GPU?&lt;/li&gt;
&lt;li&gt;How much VRAM do I need for local AI?&lt;/li&gt;
&lt;li&gt;Is NVIDIA required for local LLMs?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actual page had three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is VRAM more important than raw GPU speed?&lt;/li&gt;
&lt;li&gt;Can two GPUs combine VRAM?&lt;/li&gt;
&lt;li&gt;Should I buy a prebuilt AI PC?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So I had one FAQ for visitors and a different one sitting in the code for anything reading the structured data.&lt;/p&gt;

&lt;p&gt;Both were on the same page. Both looked reasonable on their own. Neither matched the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  I can see how I got there
&lt;/h2&gt;

&lt;p&gt;On these pages, I had essentially been keeping the FAQ in two places.&lt;/p&gt;

&lt;p&gt;One copy was the HTML people read. The other was JSON-LD, usually in the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;They might start out matching, but then I rewrite an answer, change a question or add something to the page.&lt;/p&gt;

&lt;p&gt;And forget to update the other copy.&lt;/p&gt;

&lt;p&gt;Nothing obvious breaks.&lt;/p&gt;

&lt;p&gt;The page still loads. The FAQ still opens. The JSON can still be perfectly valid.&lt;/p&gt;

&lt;p&gt;But valid JSON doesn't mean it describes what's actually on the page.&lt;/p&gt;

&lt;p&gt;That was the part I had missed.&lt;/p&gt;

&lt;h2&gt;
  
  
  I fixed the page and thought I was done
&lt;/h2&gt;

&lt;p&gt;I rebuilt the GPU page's schema from the FAQ visitors could actually read.&lt;/p&gt;

&lt;p&gt;Then I checked my email header analyzer and found a less obvious version of the same problem.&lt;/p&gt;

&lt;p&gt;It had six questions. One question had a different title in the schema, and all six answers were worded differently from the answers on the page.&lt;/p&gt;

&lt;p&gt;I fixed those too.&lt;/p&gt;

&lt;p&gt;On another page, I added two FAQ entries and checked that those two matched in both places.&lt;/p&gt;

&lt;p&gt;They did.&lt;/p&gt;

&lt;p&gt;So, naturally, I thought I was making progress.&lt;/p&gt;

&lt;p&gt;Then I wrote a scanner to check the whole site. Mostly because I wanted to confirm I hadn't left anything else behind.&lt;/p&gt;

&lt;p&gt;That turned out to be optimistic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers were worse than I expected
&lt;/h2&gt;

&lt;p&gt;The scan found:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I checked&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pages with &lt;code&gt;FAQPage&lt;/code&gt; markup&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages where schema and visible text matched under the check&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pages with mismatches&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;28&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions in the schema&lt;/td&gt;
&lt;td&gt;334&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema questions not found on the page&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions found, but with answer text that didn't match&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On the mining calculator, the schema had six questions and the page had five. None of the questions matched.&lt;/p&gt;

&lt;p&gt;The About page had the same problem.&lt;/p&gt;

&lt;p&gt;And the email security checker, which I had considered finished a few hours earlier, still had three original questions worded differently in the two places.&lt;/p&gt;

&lt;p&gt;One version said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why does the check say it could not verify DKIM?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The other said:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why does it say DKIM could not be verified?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Those mean pretty much the same thing. But they were another sign that I was maintaining two separate copies instead of one source.&lt;/p&gt;

&lt;p&gt;I had checked the entries I'd just added.&lt;/p&gt;

&lt;p&gt;I hadn't checked the whole FAQ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Of course, my first scanner was wrong too
&lt;/h2&gt;

&lt;p&gt;The first version looked for a particular HTML pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;details&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;summary&amp;gt;&lt;/span&gt;A question&lt;span class="nt"&gt;&amp;lt;/summary&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;An answer&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/details&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then told me that a lot of my pages had no visible FAQ at all.&lt;/p&gt;

&lt;p&gt;Except I could open those pages and see the questions right there.&lt;/p&gt;

&lt;p&gt;Some used headings and paragraphs. Some answers had more than one paragraph.&lt;/p&gt;

&lt;p&gt;My scanner wasn't finding them because I had told it what the markup should look like instead of accounting for what was actually there.&lt;/p&gt;

&lt;p&gt;So now I had a checker that needed checking.&lt;/p&gt;

&lt;p&gt;The next version removed scripts and styles, extracted the page text, and compared that with the questions and answers in the schema.&lt;/p&gt;

&lt;p&gt;The basic idea was:&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;norm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;visible&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visibleText&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;q&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;faq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mainEntity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`question not on the page: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;acceptedAnswer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`answer differs from the page: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That caught the differences I was looking for without depending on one FAQ layout.&lt;/p&gt;

&lt;p&gt;It still has limits. Finding a question and an answer somewhere in the page text doesn't prove they're paired correctly. And this simplified normalization is meant for my English-language pages, not every language or every kind of content.&lt;/p&gt;

&lt;p&gt;But it gave me a much more useful check than assuming every page used the same HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was this hurting my SEO?
&lt;/h2&gt;

&lt;p&gt;I don't want to turn this into a bigger SEO story than the evidence supports.&lt;/p&gt;

&lt;p&gt;Google stopped showing FAQ rich results on May 7, 2026. Its general structured-data guidelines still require marked-up content to be visible to readers.&lt;/p&gt;

&lt;p&gt;Those are separate points.&lt;/p&gt;

&lt;p&gt;The mismatches were real, but I don't have evidence that they cost me rankings.&lt;/p&gt;

&lt;p&gt;What bothered me was simpler: my page was saying two different things depending on which copy you read.&lt;/p&gt;

&lt;p&gt;This time it was FAQ wording. With a calculator, it could be a number shown in the explanation that no longer matches the number used in the calculation.&lt;/p&gt;

&lt;p&gt;That's the sort of thing I want to catch before someone else has to point it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the copies isn't enough
&lt;/h2&gt;

&lt;p&gt;I could go through all 28 pages and edit the JSON by hand.&lt;/p&gt;

&lt;p&gt;That would make them match today.&lt;/p&gt;

&lt;p&gt;It wouldn't stop me from changing an answer next week and creating the same problem again.&lt;/p&gt;

&lt;p&gt;So the approach I'm moving toward is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the visible FAQ as the source.&lt;/strong&gt; Generate the schema from it at build time instead of editing both copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the whole site on each build.&lt;/strong&gt; Include pages I didn't touch that day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't add &lt;code&gt;FAQPage&lt;/code&gt; markup without a visible FAQ.&lt;/strong&gt; There shouldn't be a separate set of answers available only in the structured data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://stashgrid.site/llm/" rel="noopener noreferrer"&gt;StashGrid local LLM GPU comparison&lt;/a&gt; was where this started. By the time I ran this scan, its seven questions matched in both places. The scan had also given me a list of 28 other pages to work through.&lt;/p&gt;

&lt;p&gt;The lesson for me was pretty basic.&lt;/p&gt;

&lt;p&gt;Checking that my latest edit is correct isn't the same as checking that the page is correct.&lt;/p&gt;

&lt;p&gt;And if I'm keeping the same information in two places, I need something that makes sure it stays the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; all 28 now match. Each page's FAQ is fenced in one marked block, the schema is generated from it, and a weekly check fails if they drift.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>javascript</category>
      <category>testing</category>
    </item>
    <item>
      <title>My AI Crawler Analyzed an Error Page Like It Was the Real Site</title>
      <dc:creator>Steven Browning</dc:creator>
      <pubDate>Sun, 04 Oct 2026 19:37:13 +0000</pubDate>
      <link>https://dev.to/steven_browning_70ac8fbfa/my-ai-crawler-analyzed-an-error-page-like-it-was-the-real-site-1mdj</link>
      <guid>https://dev.to/steven_browning_70ac8fbfa/my-ai-crawler-analyzed-an-error-page-like-it-was-the-real-site-1mdj</guid>
      <description>&lt;p&gt;I built a small tool recently that checks how visible a webpage is to AI crawlers.&lt;/p&gt;

&lt;p&gt;You give it a URL, and it tries to fetch the page the way a crawler might. Then it checks things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the page actually respond?&lt;/li&gt;
&lt;li&gt;Is there a &lt;code&gt;noindex&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Does &lt;code&gt;robots.txt&lt;/code&gt; allow GPTBot, ClaudeBot and other AI crawlers?&lt;/li&gt;
&lt;li&gt;Is there a title?&lt;/li&gt;
&lt;li&gt;Is there an H1?&lt;/li&gt;
&lt;li&gt;Is there readable content?&lt;/li&gt;
&lt;li&gt;Is there structured data?&lt;/li&gt;
&lt;li&gt;Is there a canonical URL?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pretty straightforward.&lt;/p&gt;

&lt;p&gt;Fetch the page, look at what came back, report what you find.&lt;/p&gt;

&lt;p&gt;At least that was the idea.&lt;/p&gt;

&lt;p&gt;Then I pointed it at &lt;code&gt;nytimes.com&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My tool reported:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No H1&lt;/li&gt;
&lt;li&gt;Only about 81 words of readable text&lt;/li&gt;
&lt;li&gt;No JSON-LD structured data&lt;/li&gt;
&lt;li&gt;No canonical URL&lt;/li&gt;
&lt;li&gt;No meta description&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Technically, every one of those statements was correct.&lt;/p&gt;

&lt;p&gt;They were also completely useless.&lt;/p&gt;

&lt;p&gt;The server had returned &lt;strong&gt;HTTP 403&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My tool received 771 bytes of an error response and then happily analyzed it as if it were the New York Times homepage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nothing was actually broken
&lt;/h2&gt;

&lt;p&gt;This is what made the problem interesting to me.&lt;/p&gt;

&lt;p&gt;The code looking for an H1 worked.&lt;/p&gt;

&lt;p&gt;There wasn't an H1 in the response, so it correctly reported that there wasn't one.&lt;/p&gt;

&lt;p&gt;The word counter worked.&lt;/p&gt;

&lt;p&gt;There were about 81 words, so it reported 81.&lt;/p&gt;

&lt;p&gt;The structured-data check worked.&lt;/p&gt;

&lt;p&gt;There wasn't any JSON-LD in the response.&lt;/p&gt;

&lt;p&gt;Everything did exactly what I had asked it to do.&lt;/p&gt;

&lt;p&gt;The problem was that I never really asked the most important question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did I actually get the page I wanted to analyze?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The code was basically doing 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;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;UA&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;html&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;page&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;extract&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It went straight from "I received something" to "let's analyze it."&lt;/p&gt;

&lt;p&gt;The funny part is that &lt;code&gt;res.status&lt;/code&gt; was already available.&lt;/p&gt;

&lt;p&gt;I was even displaying it.&lt;/p&gt;

&lt;p&gt;There was a little grey row near the bottom of the report that said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status: 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Meanwhile, above that, the tool was confidently telling me what was supposedly wrong with the page.&lt;/p&gt;

&lt;p&gt;So technically the information was there.&lt;/p&gt;

&lt;p&gt;It just wasn't being treated like the most important information on the page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was embarrassingly small
&lt;/h2&gt;

&lt;p&gt;Once I realized what was happening, the first fix was only a few lines:&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;findings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`The page returned HTTP &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;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; to our crawler`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Everything below was read from the error response, not from the real page.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was another problem hiding in the same area.&lt;/p&gt;

&lt;p&gt;The scanner could also display:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nothing is blocking this page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That message was based on whether the findings list was empty.&lt;/p&gt;

&lt;p&gt;It wasn't based on whether the page had actually loaded successfully.&lt;/p&gt;

&lt;p&gt;So under the right conditions, a site could refuse the request completely and my tool could still give it the equivalent of an all-clear.&lt;/p&gt;

&lt;p&gt;That obviously wasn't what I intended.&lt;/p&gt;

&lt;h2&gt;
  
  
  I'm starting to see a pattern
&lt;/h2&gt;

&lt;p&gt;I'm not a professional developer.&lt;/p&gt;

&lt;p&gt;I'm mostly building these tools with AI, experimenting, breaking things, fixing them and learning what I should have checked the first time.&lt;/p&gt;

&lt;p&gt;And I've noticed that a lot of the mistakes I've run into have the same basic shape.&lt;/p&gt;

&lt;p&gt;I previously had a GPU mining calculator tell me an RTX 4090 could make more than &lt;strong&gt;$5,000 a month&lt;/strong&gt; because I mixed up GH/s and TH/s.&lt;/p&gt;

&lt;p&gt;I also had calculators where a perfectly valid value of zero was treated as if there was no answer at all.&lt;/p&gt;

&lt;p&gt;Different bugs, but the same general problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Something receives an input nobody really questioned, processes it correctly, and produces a very confident answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing crashes.&lt;/p&gt;

&lt;p&gt;There isn't necessarily an error message.&lt;/p&gt;

&lt;p&gt;The page loads.&lt;/p&gt;

&lt;p&gt;The report looks good.&lt;/p&gt;

&lt;p&gt;The answer is just wrong.&lt;/p&gt;

&lt;p&gt;In this case, every part of the scanner worked.&lt;/p&gt;

&lt;p&gt;It was simply analyzing the wrong thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm checking now
&lt;/h2&gt;

&lt;p&gt;The lesson for me wasn't that I need a better HTML parser.&lt;/p&gt;

&lt;p&gt;It was that before analyzing something, I need to make sure I actually received what I think I received.&lt;/p&gt;

&lt;p&gt;There are a few surprisingly basic checks that would have caught this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did the request actually succeed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;403&lt;/code&gt; response is still a response. JavaScript will happily hand me the body and let me analyze it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did I get the type of content I expected?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If I'm expecting HTML and receive JSON, a PDF, a login page or an error message, searching it for an H1 doesn't tell me much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the size make any sense?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The response from &lt;code&gt;nytimes.com&lt;/code&gt; was 771 bytes.&lt;/p&gt;

&lt;p&gt;That alone should have made me suspicious that I probably wasn't looking at a major news homepage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did I end up at the URL I expected?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A request can follow redirects and eventually land on a login page, error page or something completely different from the URL that was originally entered.&lt;/p&gt;

&lt;p&gt;The biggest change I'm making is what happens when one of those checks fails.&lt;/p&gt;

&lt;p&gt;Instead of producing a normal report with a warning somewhere in it, the tool now needs to say, prominently:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I couldn't properly inspect this page, and here's why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Anything it found inside the error response becomes secondary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I almost made the exact same mistake in this post
&lt;/h2&gt;

&lt;p&gt;This part was a little embarrassing.&lt;/p&gt;

&lt;p&gt;I originally had a much better opening planned.&lt;/p&gt;

&lt;p&gt;The idea was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;nytimes.com&lt;/code&gt; loads normally in a browser but returns 403 to an AI crawler.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a nice example.&lt;/p&gt;

&lt;p&gt;There's only one problem.&lt;/p&gt;

&lt;p&gt;Before publishing this, I actually checked it.&lt;/p&gt;

&lt;p&gt;I requested the same page from the same machine using an ordinary Chrome user agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;403 again.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I don't actually know that the site was specifically rejecting my crawler.&lt;/p&gt;

&lt;p&gt;It could be my IP range. It could require more of a normal browser fingerprint. It could be something else entirely.&lt;/p&gt;

&lt;p&gt;All I can say from my test is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The server refused the requests I made, and my scanner analyzed that refusal as if it were the real webpage.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which is enough to demonstrate the problem.&lt;/p&gt;

&lt;p&gt;The irony wasn't lost on me.&lt;/p&gt;

&lt;p&gt;I built a tool because I wanted evidence instead of assumptions, and then nearly opened the article with an assumption I hadn't verified.&lt;/p&gt;

&lt;p&gt;Checking it took about ninety seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  There was still useful information
&lt;/h2&gt;

&lt;p&gt;One thing I could verify separately was &lt;code&gt;robots.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That request returned HTTP 200.&lt;/p&gt;

&lt;p&gt;Of the ten crawlers my tool checks, eight were disallowed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GPTBot&lt;/li&gt;
&lt;li&gt;OAI-SearchBot&lt;/li&gt;
&lt;li&gt;ChatGPT-User&lt;/li&gt;
&lt;li&gt;ClaudeBot&lt;/li&gt;
&lt;li&gt;Claude-User&lt;/li&gt;
&lt;li&gt;PerplexityBot&lt;/li&gt;
&lt;li&gt;Google-Extended&lt;/li&gt;
&lt;li&gt;CCBot&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Googlebot and Bingbot were allowed.&lt;/p&gt;

&lt;p&gt;That's actually a useful distinction for this kind of tool.&lt;/p&gt;

&lt;p&gt;A website can make different choices about traditional search engines and AI-related crawlers. Those aren't necessarily the same thing, and putting everything under a single "AI visibility score" can hide that.&lt;/p&gt;

&lt;p&gt;Which is one reason I don't give the scanner a score out of 100.&lt;/p&gt;

&lt;p&gt;A page with a slightly short meta description and a page with &lt;code&gt;noindex&lt;/code&gt; shouldn't just lose a different number of points.&lt;/p&gt;

&lt;p&gt;One is a small optimization issue.&lt;/p&gt;

&lt;p&gt;The other can prevent the page from appearing where you expect it to.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to try it
&lt;/h2&gt;

&lt;p&gt;The tool is &lt;strong&gt;&lt;a href="https://stashgrid.site/ai-visibility/" rel="noopener noreferrer"&gt;StashGrid AI Visibility&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It's free, doesn't require an account and doesn't store the URLs you enter.&lt;/p&gt;

&lt;p&gt;Paste in a URL and it checks what the crawler actually receives, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;HTTP status&lt;/li&gt;
&lt;li&gt;Redirects&lt;/li&gt;
&lt;li&gt;&lt;code&gt;noindex&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;X-Robots-Tag&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; rules for ten named crawlers&lt;/li&gt;
&lt;li&gt;Canonical URL&lt;/li&gt;
&lt;li&gt;H1&lt;/li&gt;
&lt;li&gt;Structured data&lt;/li&gt;
&lt;li&gt;Whether useful page content is actually present in the HTML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The HTTP response now comes first.&lt;/p&gt;

&lt;p&gt;If the page returns a 403, that's the headline.&lt;/p&gt;

&lt;p&gt;Not the missing H1 buried inside the error page.&lt;/p&gt;

&lt;p&gt;I also rank findings as &lt;strong&gt;blocker&lt;/strong&gt;, &lt;strong&gt;warning&lt;/strong&gt; or &lt;strong&gt;info&lt;/strong&gt; instead of turning everything into one score.&lt;/p&gt;

&lt;p&gt;And wherever possible, the scanner shows the evidence that caused the finding.&lt;/p&gt;

&lt;p&gt;I want someone using it to be able to look at the result and decide whether they agree with it.&lt;/p&gt;

&lt;p&gt;For comparison, I ran it against one of my own pages that it could actually reach.&lt;/p&gt;

&lt;p&gt;HTTP 200.&lt;/p&gt;

&lt;p&gt;No redirects.&lt;/p&gt;

&lt;p&gt;One H1.&lt;/p&gt;

&lt;p&gt;2,272 words of readable content.&lt;/p&gt;

&lt;p&gt;Three JSON-LD blocks.&lt;/p&gt;

&lt;p&gt;A self-referencing canonical.&lt;/p&gt;

&lt;p&gt;All ten crawlers allowed.&lt;/p&gt;

&lt;p&gt;It's a much more boring report.&lt;/p&gt;

&lt;p&gt;Which is exactly what I want.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>debugging</category>
      <category>ai</category>
    </item>
    <item>
      <title>Nine bugs, one cause: my calculators all thought zero meant nothing</title>
      <dc:creator>Steven Browning</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:12:44 +0000</pubDate>
      <link>https://dev.to/steven_browning_70ac8fbfa/nine-bugs-one-cause-my-calculators-all-thought-zero-meant-nothing-3bp8</link>
      <guid>https://dev.to/steven_browning_70ac8fbfa/nine-bugs-one-cause-my-calculators-all-thought-zero-meant-nothing-3bp8</guid>
      <description>&lt;p&gt;I spent two days checking the arithmetic on fifteen of my calculator pages. The audit turned up 34 defects. Nine came back to the same mistake: treating zero as if the user had left something blank.&lt;/p&gt;

&lt;p&gt;What bothered me was how normal the code looked. These were separate tools, built at different times, and I had repeated the same assumption across them.&lt;/p&gt;

&lt;p&gt;Here is one example:&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;inflationRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inflationRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty field? Use 3%. Seems reasonable.&lt;/p&gt;

&lt;p&gt;Enter &lt;code&gt;0&lt;/code&gt;? Still 3%.&lt;/p&gt;

&lt;p&gt;The calculator quietly replaced the number the person actually entered. No warning, no error, just a different calculation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where zero went missing
&lt;/h2&gt;

&lt;p&gt;These are examples from the browser audit, using the pages' own fields and buttons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retirement calculator:&lt;/strong&gt; entering &lt;code&gt;0&lt;/code&gt; for inflation still applied 3%. With the other inputs unchanged, the target stayed at &lt;strong&gt;$1,884,400&lt;/strong&gt; instead of &lt;strong&gt;$900,000&lt;/strong&gt;. If someone wants to compare a scenario without inflation, zero needs to stay zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;EV vs. petrol calculator:&lt;/strong&gt; entering &lt;code&gt;0&lt;/code&gt; for electricity still used 14¢/kWh. In the test case, that produced &lt;strong&gt;$480 a year&lt;/strong&gt; in charging costs instead of &lt;strong&gt;$0&lt;/strong&gt;. Free workplace charging is a perfectly reasonable scenario to compare. The calculator should allow it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credit card payoff:&lt;/strong&gt; entering 0% APR cleared the results without explaining why. This was the check:&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;apr&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;dueDateStr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero APR failed the same check as a missing APR. That rules out an ordinary use case: a promotional 0% offer. The offer's duration needs its own handling, but zero itself is still a valid rate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GPU mining profitability:&lt;/strong&gt; this one was in the output logic:&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;breakEvenDays&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;profitDay&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;gpuCost&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;profitDay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;breakEvenDays&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Show the breakeven time.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;res-breakeven&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;textContent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Never&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no new hardware cost and positive daily profit, the result is &lt;code&gt;0&lt;/code&gt; days. But &lt;code&gt;if (0)&lt;/code&gt; is false, so the page showed &lt;strong&gt;"Never / Not profitable at this rate"&lt;/strong&gt; alongside &lt;strong&gt;+$0.27/day&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It should distinguish zero days from no breakeven. For this model, that means checking &lt;code&gt;breakEvenDays !== null&lt;/code&gt;, rather than whether the number is truthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two GPU comparison tables:&lt;/strong&gt; a &lt;code&gt;$0&lt;/code&gt; budget showed every card because &lt;code&gt;|| Infinity&lt;/code&gt; turned zero into an unlimited budget. One table also selected a default GPU when the filtered list was empty. It managed to show &lt;strong&gt;"0 cards"&lt;/strong&gt; beside a named recommendation.&lt;/p&gt;

&lt;p&gt;Those are different screens, but the question I had failed to ask was the same: does zero mean something here?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I missed it
&lt;/h2&gt;

&lt;p&gt;I had checked empty fields, typical values and very large values. I had not consistently tested zero as its own case.&lt;/p&gt;

&lt;p&gt;Looking at the code did not make the problem obvious either. A fallback beside an optional field looks helpful. A truthiness check looks like basic validation. And a number type alone cannot tell you whether zero is allowed for a particular field.&lt;/p&gt;

&lt;p&gt;I also relied too much on attributes such as &lt;code&gt;min="0"&lt;/code&gt; and &lt;code&gt;max="100"&lt;/code&gt;. They define validation constraints; they do not automatically stop a custom calculation handler from reading an invalid value. The page still needs to invoke validation or check the inputs itself. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input/number" rel="noopener noreferrer"&gt;MDN explains how number-input validation works&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Blank, zero and invalid need separate decisions
&lt;/h2&gt;

&lt;p&gt;I would not replace every &lt;code&gt;||&lt;/code&gt; just because of this audit.&lt;/p&gt;

&lt;p&gt;On a travel budget page, a blank optional cost might intentionally mean zero:&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;flights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flights&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That does preserve a typed zero, because the fallback is also zero. It can still hide invalid input, though, so it is not a complete validation strategy.&lt;/p&gt;

&lt;p&gt;On a fuel calculator, zero miles per gallon is a different problem: MPG is a divisor. The page should reject it with an explanation, rather than silently replace it with 30.&lt;/p&gt;

&lt;p&gt;For an optional numeric field, I would make those choices explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readOptionalNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallback&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;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;fallback&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter a valid number.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&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;inflationRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readOptionalNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inflationRate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The caller still needs to show the error beside the field and check any allowed range. This helper only separates blank, zero and non-finite input.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;parseFloat(raw) ?? 3&lt;/code&gt; is not a drop-in fix. An empty string produces &lt;code&gt;NaN&lt;/code&gt;, and &lt;code&gt;??&lt;/code&gt; only falls back for &lt;code&gt;null&lt;/code&gt; or &lt;code&gt;undefined&lt;/code&gt;. These are separate behaviors: &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/parseFloat" rel="noopener noreferrer"&gt;parsing a number&lt;/a&gt; and &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing" rel="noopener noreferrer"&gt;nullish coalescing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Another way to lose a number: formatting
&lt;/h2&gt;

&lt;p&gt;The percentage calculator had a separate issue. It displayed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0.01% of 1 = 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer is &lt;code&gt;0.0001&lt;/code&gt;. This time, a falsy check was not responsible. The formatter was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;rounded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rounded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&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;It rounded to four decimal places, then handed the number to a formatter that normally displays at most three fractional digits for plain numbers. The fourth decimal disappeared. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat" rel="noopener noreferrer"&gt;The default is documented here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If four decimal places are the intended display limit, say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;maximumFractionDigits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&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;That fixes this example. It still rounds smaller values, so a tool that needs to show those should use more precision or scientific notation. The displayed answer deserves a test too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then there was the opposite problem
&lt;/h2&gt;

&lt;p&gt;The maths page checked for an exact zero before calculating tangent:&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;tanV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;r&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&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;At 90 degrees, JavaScript's cosine calculation returns approximately &lt;code&gt;6.123233995736766e-17&lt;/code&gt;, not exactly zero. The guard passed, and the page displayed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cos = 0
tan = 16331239353195370
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Underneath was a note explaining that tangent is undefined at 90° and 270°. The explanation was right; the result above it was wrong.&lt;/p&gt;

&lt;p&gt;A tolerance can help a degree-based display handle that boundary:&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;nearPole&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;12&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;tanV&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nearPole&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;r&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rad&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That threshold is a display choice, not a universal floating-point fix. It also catches angles very close to a pole where tangent is large but defined. Choose the rule for the tool's accepted inputs and intended precision, then test both the boundary and nearby values. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/EPSILON" rel="noopener noreferrer"&gt;Tolerance depends on scale and input accuracy&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The audit needed checking as well
&lt;/h2&gt;

&lt;p&gt;Two early findings turned out to be mistakes in how I tested the pages. I called an internal function directly and skipped a guard that ran when a visitor clicked the button.&lt;/p&gt;

&lt;p&gt;That changed how I checked the remaining issues: enter the values, click the actual button, and inspect what the visitor sees. A suspicious line of code is a reason to investigate, not proof that the live page is broken.&lt;/p&gt;

&lt;p&gt;Checks on the fixes helped too. One caught a hardcoded year I had updated in one function but missed in another. A few checks were wrong themselves and needed correcting. Useful checks need review just like the code they test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would check first now
&lt;/h2&gt;

&lt;p&gt;For each numeric field:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Leave it blank.&lt;/li&gt;
&lt;li&gt;Enter zero.&lt;/li&gt;
&lt;li&gt;Enter a small positive decimal.&lt;/li&gt;
&lt;li&gt;Try an invalid or out-of-range value.&lt;/li&gt;
&lt;li&gt;Check the displayed result, not just the value inside the function.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The frustrating part was that these pages still looked fine. They loaded, the layouts worked, and the numbers were neatly formatted. That made the wrong answers easy to trust.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://stashgrid.site/percent/" rel="noopener noreferrer"&gt;free calculators at StashGrid.site&lt;/a&gt;. After this audit, zero gets its own test case.&lt;/p&gt;

&lt;p&gt;If you have a calculator or numeric form, try zero in the fields where it is allowed. Does it stay zero, trigger a useful message, or quietly become something else?&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My calculator told people a GPU earns $171 a day. It loses $1.12</title>
      <dc:creator>Steven Browning</dc:creator>
      <pubDate>Wed, 23 Sep 2026 15:32:16 +0000</pubDate>
      <link>https://dev.to/steven_browning_70ac8fbfa/my-calculator-told-people-a-gpu-earns-171-a-day-it-loses-112-563h</link>
      <guid>https://dev.to/steven_browning_70ac8fbfa/my-calculator-told-people-a-gpu-earns-171-a-day-it-loses-112-563h</guid>
      <description>&lt;p&gt;For about four days, a calculator I built was telling people that one RTX 4090 could make &lt;strong&gt;$171.31 a day&lt;/strong&gt; mining Ergo.&lt;/p&gt;

&lt;p&gt;That worked out to around &lt;strong&gt;$5,101 a month&lt;/strong&gt;, with a $1,799 graphics card supposedly paying for itself in &lt;strong&gt;11 days&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Yeah. That was very wrong.&lt;/p&gt;

&lt;p&gt;The real result with the same settings was closer to &lt;strong&gt;18 cents a day in revenue&lt;/strong&gt; and about a &lt;strong&gt;$1.12 loss per day after electricity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Break-even wasn't 11 days.&lt;/p&gt;

&lt;p&gt;It was never.&lt;/p&gt;

&lt;p&gt;I wanted to write this up because I'm not a professional developer. I'm probably closer to what people now call a &lt;strong&gt;vibe coder&lt;/strong&gt;. I know enough to build things, troubleshoot them, change code, use AI to help me, and usually figure out why something isn't working.&lt;/p&gt;

&lt;p&gt;But I'm still learning as I go.&lt;/p&gt;

&lt;p&gt;And this was one of those bugs where everything looked completely reasonable until I stopped and said:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There is absolutely no way this number is real.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the calculator was showing
&lt;/h2&gt;

&lt;p&gt;If you loaded the page and didn't change anything, you'd see something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Daily revenue     $171.31
Daily profit      +$170.02    Profitable ✅
Monthly           +$5,101
Break-even        11 days
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even worse, basically every coin in the comparison table looked profitable.&lt;/p&gt;

&lt;p&gt;They weren't.&lt;/p&gt;

&lt;p&gt;Anyone who knows GPU mining would probably spot the problem immediately.&lt;/p&gt;

&lt;p&gt;If you could really buy an RTX 4090 for around $1,800 and recover the cost in 11 days, people would be buying every 4090 they could find.&lt;/p&gt;

&lt;p&gt;But the whole reason I built a calculator like this is for people who &lt;strong&gt;don't already know the answer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you're researching whether mining is worth trying, you might look at that number and think:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wow, I didn't realize GPU mining was still this profitable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's where a bad calculator becomes more than just a funny bug.&lt;/p&gt;

&lt;p&gt;Someone could actually make a purchasing decision based on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what went wrong?
&lt;/h2&gt;

&lt;p&gt;Part of the site uses automatically updated mining data.&lt;/p&gt;

&lt;p&gt;The data included a value like this:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERG"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"algorithm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Autolykos2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rev_per_th_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1310.945239&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the unit.&lt;/p&gt;

&lt;p&gt;That number was based on &lt;strong&gt;terahashes per second&lt;/strong&gt;, or TH/s.&lt;/p&gt;

&lt;p&gt;Elsewhere, the calculator was doing 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;revPerTh&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coinOpt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rev&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;totalHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hashrate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// MH/s -&amp;gt; GH/s&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grossRev&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;totalHash&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;revPerTh&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had one value in &lt;strong&gt;GH/s&lt;/strong&gt; and another value based on &lt;strong&gt;TH/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Those aren't the same thing.&lt;/p&gt;

&lt;p&gt;There's a factor of &lt;strong&gt;1,000&lt;/strong&gt; between them.&lt;/p&gt;

&lt;p&gt;Once I understood that, the crazy result suddenly made perfect sense.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;132 MH/s = 0.132 GH/s

0.132 × 1310.945 = $173.04

After the pool fee = $171.31
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matched exactly what the site was showing.&lt;/p&gt;

&lt;p&gt;So the calculator wasn't randomly broken.&lt;/p&gt;

&lt;p&gt;It was very confidently doing the wrong math because I had given it two values using different units.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is the part that surprised me
&lt;/h2&gt;

&lt;p&gt;Nothing looked obviously broken.&lt;/p&gt;

&lt;p&gt;The data file was loading.&lt;/p&gt;

&lt;p&gt;The numbers were valid.&lt;/p&gt;

&lt;p&gt;The calculator was doing multiplication correctly.&lt;/p&gt;

&lt;p&gt;The page wasn't throwing errors.&lt;/p&gt;

&lt;p&gt;From the browser's point of view, everything was working.&lt;/p&gt;

&lt;p&gt;That's probably one of the bigger lessons for me from building things with AI.&lt;/p&gt;

&lt;p&gt;AI can help you write code that runs.&lt;/p&gt;

&lt;p&gt;It can help fix syntax errors.&lt;/p&gt;

&lt;p&gt;It can help explain functions.&lt;/p&gt;

&lt;p&gt;It can even help build a pretty complicated tool.&lt;/p&gt;

&lt;p&gt;But &lt;strong&gt;code running successfully doesn't mean the answer makes sense&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In this case, the most useful debugging tool ended up being common sense.&lt;/p&gt;

&lt;p&gt;An RTX 4090 does not make $5,000 a month mining Ergo.&lt;/p&gt;

&lt;p&gt;Once I accepted that the output itself had to be wrong, it became much easier to work backward and find out why.&lt;/p&gt;

&lt;h2&gt;
  
  
  One comment actually made me ignore the problem
&lt;/h2&gt;

&lt;p&gt;The page loaded the live mining data using code like 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="c1"&gt;// fails silently if unavailable&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/mining/mining-data.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;applyLiveCoins&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had looked at that comment multiple times.&lt;/p&gt;

&lt;p&gt;In my head, I read it as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the live data doesn't work, no big deal. The page has fallback values.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I more or less stopped paying attention to that section.&lt;/p&gt;

&lt;p&gt;Except the live data &lt;strong&gt;wasn't failing&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It was loading correctly every time.&lt;/p&gt;

&lt;p&gt;And every time it loaded, it was replacing the reasonable fallback numbers with the bad ones.&lt;/p&gt;

&lt;p&gt;So the part I had mentally categorized as harmless was actually the thing causing the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  My first fix wasn't really enough
&lt;/h2&gt;

&lt;p&gt;My first reaction was basically:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fine. Make the variable names more obvious.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Instead of something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;revPerTh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;use something that spells the unit out.&lt;/p&gt;

&lt;p&gt;That's definitely better.&lt;/p&gt;

&lt;p&gt;But then I realized something.&lt;/p&gt;

&lt;p&gt;The bad number was so ridiculous that the site probably should have rejected it regardless of how the mistake happened.&lt;/p&gt;

&lt;p&gt;So I added what is basically a sanity check:&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;REV_SANITY_MIN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0001&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;REV_SANITY_MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;usable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&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;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;usd_per_ghs_day&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;REV_SANITY_MIN&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;REV_SANITY_MAX&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;symbol&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;usable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'm not trying to say the value has to be perfectly accurate.&lt;/p&gt;

&lt;p&gt;I'm just saying:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If this number is completely outside the range of anything believable, don't automatically publish it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And if the live data looks suspicious, the page keeps the older known-good numbers instead.&lt;/p&gt;

&lt;p&gt;I'd rather show slightly outdated numbers than confidently tell someone their GPU is going to print $5,000 a month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I started noticing the same kind of problem everywhere
&lt;/h2&gt;

&lt;p&gt;Once I started looking for numbers that were technically valid but obviously wrong, I found more of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What I saw&lt;/th&gt;
&lt;th&gt;What was actually happening&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Used RTX 4090 for $171.68&lt;/td&gt;
&lt;td&gt;The scraper grabbed a cable instead of a GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX 3090 for $2,399&lt;/td&gt;
&lt;td&gt;The search also matched an RTX 3090 Ti&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Insane mining revenue numbers&lt;/td&gt;
&lt;td&gt;The reference hardware was an ASIC, not a GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;$5,101/month from one RTX 4090&lt;/td&gt;
&lt;td&gt;GH/s and TH/s got mixed together&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The funny part is that none of these were really complicated programming mistakes.&lt;/p&gt;

&lt;p&gt;The code was doing what I told it to do.&lt;/p&gt;

&lt;p&gt;The problem was that what I told it to do didn't match reality.&lt;/p&gt;

&lt;p&gt;I even managed to create basically the same bug while fixing another one.&lt;/p&gt;

&lt;p&gt;I was trying to filter RTX 3090 Ti cards out of RTX 3090 results.&lt;/p&gt;

&lt;p&gt;So I checked whether the listing contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ti
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That seemed reasonable.&lt;/p&gt;

&lt;p&gt;Until I realized a perfectly valid listing said:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Founders Edition
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which also contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ti
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So my fix for bad matching was itself doing bad matching.&lt;/p&gt;

&lt;p&gt;That one made me laugh once I figured it out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sanity checks can be wrong too
&lt;/h2&gt;

&lt;p&gt;There's another side to this.&lt;/p&gt;

&lt;p&gt;You have to be careful not to assume that every surprising number is bad data.&lt;/p&gt;

&lt;p&gt;I ran into that while working on another GPU tool for local AI performance.&lt;/p&gt;

&lt;p&gt;I expected cards with higher memory bandwidth to generally perform better.&lt;/p&gt;

&lt;p&gt;Then I had an RTX 3090 with around &lt;strong&gt;936 GB/s&lt;/strong&gt; of bandwidth showing worse results than a 4070 Ti Super with around &lt;strong&gt;672 GB/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My first reaction was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The benchmark must be wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Except it wasn't.&lt;/p&gt;

&lt;p&gt;The newer architecture was simply using its available bandwidth more efficiently.&lt;/p&gt;

&lt;p&gt;So now I'm trying to treat these sanity checks as warnings, not corrections.&lt;/p&gt;

&lt;p&gt;The system should basically say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This looks strange. Check it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is definitely wrong, so I'm going to change it automatically.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;Otherwise I'm just replacing one bad assumption with another one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned from it
&lt;/h2&gt;

&lt;p&gt;The main thing I'm changing in how I build these tools is pretty simple.&lt;/p&gt;

&lt;p&gt;Whenever I'm showing a calculated number to someone, especially something involving money, I want to ask:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this result pass a basic reality check?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not just:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the code run?&lt;/li&gt;
&lt;li&gt;Did the JSON load?&lt;/li&gt;
&lt;li&gt;Is this a valid number?&lt;/li&gt;
&lt;li&gt;Did the formula execute?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Could this number actually make sense in the real world?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm still going to use AI heavily to build things.&lt;/p&gt;

&lt;p&gt;That's part of what makes projects like StashGrid possible for me.&lt;/p&gt;

&lt;p&gt;But this was a good reminder that AI-generated code, APIs, automated feeds, tests, and working JavaScript still need a human somewhere in the loop asking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Wait...does that actually make sense?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In this case, the answer was definitely no.&lt;/p&gt;

&lt;p&gt;The calculator that caused all of this is the &lt;a href="https://stashgrid.site/mining/" rel="noopener noreferrer"&gt;GPU Mining Calculator on StashGrid&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The numbers are a lot less exciting now.&lt;/p&gt;

&lt;p&gt;Which is probably a good sign.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>programming</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Your DMARC record might contain something that no longer exists</title>
      <dc:creator>Steven Browning</dc:creator>
      <pubDate>Sat, 19 Sep 2026 22:07:42 +0000</pubDate>
      <link>https://dev.to/steven_browning_70ac8fbfa/your-dmarc-record-might-contain-something-that-no-longer-exists-nii</link>
      <guid>https://dev.to/steven_browning_70ac8fbfa/your-dmarc-record-might-contain-something-that-no-longer-exists-nii</guid>
      <description>&lt;p&gt;Most of us set up SPF, DKIM and DMARC once, watch the reports for a week or two, and then leave them alone. That's usually the right call. They aren't systems that need constant attention.&lt;/p&gt;

&lt;p&gt;This year is worth an exception, though — and not quite for the reason you'd expect. The spec changed in May, and one of those changes means that &lt;em&gt;tidying up&lt;/em&gt; your DMARC record can quietly make it stricter than you intended.&lt;/p&gt;

&lt;p&gt;Here's what moved, and what I'd look at.&lt;/p&gt;

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

&lt;p&gt;On 19 May 2026, DMARC moved onto the IETF Standards Track as &lt;a href="https://www.rfc-editor.org/info/rfc9989/" rel="noopener noreferrer"&gt;RFC 9989&lt;/a&gt;, replacing RFC 7489 and RFC 9091. Reporting moved out into two companion documents: RFC 9990 for aggregate reports, RFC 9991 for per-message failure reports.&lt;/p&gt;

&lt;p&gt;For the ten years before that, DMARC carried the status &lt;em&gt;Informational&lt;/em&gt; — a description of something the industry had already built, rather than a standard anyone was obliged to follow. A lot of records out there were written against that older spec and haven't been touched since. Some of them contain a tag that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;pct&lt;/code&gt; tag is gone, and deleting it isn't free
&lt;/h2&gt;

&lt;p&gt;If you rolled DMARC out gradually, you probably used &lt;code&gt;pct&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=DMARC1; p=quarantine; pct=25; rua=mailto:dmarc@example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was a reassuring idea: apply the policy to a quarter of failing messages, watch what breaks, then raise the number. In practice receivers interpreted it inconsistently enough that RFC 9989 removes it altogether — there's an appendix titled "Removal of the 'pct' Tag."&lt;/p&gt;

&lt;p&gt;This is the part I'd slow down for. If you delete &lt;code&gt;pct=25&lt;/code&gt; from that record, you don't land back where you started. Receivers that don't recognise the tag already treat the policy as applying to &lt;strong&gt;everything&lt;/strong&gt;, and removing it just makes that explicit. You've gone from quarantining a quarter of failing mail to quarantining all of it.&lt;/p&gt;

&lt;p&gt;That may be exactly what you want. It's nicer to be the person who decided it than the person who finds out from the helpdesk.&lt;/p&gt;

&lt;p&gt;The replacement is a &lt;code&gt;t&lt;/code&gt; tag, for testing. &lt;code&gt;t=y&lt;/code&gt; asks receivers to handle mail one step below your published policy — &lt;code&gt;p=reject&lt;/code&gt; behaves as quarantine, &lt;code&gt;p=quarantine&lt;/code&gt; behaves as none — while you carry on collecting reports. It's a cleaner mechanism than sampling, though it does mean the gradual percentage ramp isn't available any more.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stricter isn't automatically the goal
&lt;/h2&gt;

&lt;p&gt;There's an assumption running through a lot of DMARC advice that &lt;code&gt;p=reject&lt;/code&gt; is the finish line and everything else is a waypoint. RFC 9989 is more careful than that. Section 7.4 puts it directly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;domains that host users who might post messages to mailing lists SHOULD NOT publish Domain Owner Assessment Policies of &lt;code&gt;p=reject&lt;/code&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The reasoning is practical rather than theoretical. Mailing lists and forwarders relay and rewrite messages in ways that break the original authentication, so rejecting on failure can bounce your own users' perfectly legitimate mail — and list software often responds to those bounces by unsubscribing them.&lt;/p&gt;

&lt;p&gt;If your domain is purely transactional, &lt;code&gt;p=reject&lt;/code&gt; is a sound destination. If real people send mail from it, quarantine may be as far as you want to go.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three smaller changes worth knowing about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Organizational domain discovery no longer uses the Public Suffix List.&lt;/strong&gt; RFC 9989 replaces it with a series of DNS queries the spec calls the &lt;strong&gt;DNS Tree Walk&lt;/strong&gt;. Worth knowing by name — it's what you'll be searching for the day something behaves oddly on a multi-level domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;np&lt;/code&gt; covers non-existent subdomains.&lt;/strong&gt; Carried over from RFC 9091, it lets you publish a separate policy for subdomains that don't exist at all, which is handy, because those are a favourite for spoofing. Without it they inherit &lt;code&gt;sp&lt;/code&gt;, or failing that &lt;code&gt;p&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;sp&lt;/code&gt; only applies where you'd expect it to.&lt;/strong&gt; It's ignored on records published below the organizational domain or public suffix level. If you've ever set it on a subdomain and wondered why nothing happened, that's the reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  SPF: a short record can still be doing a lot of work
&lt;/h2&gt;

&lt;p&gt;An SPF record can look completely manageable and still sit close to a hard limit, because every &lt;code&gt;include:&lt;/code&gt; pulls in whatever &lt;em&gt;that&lt;/em&gt; provider has published — and their includes count too.&lt;/p&gt;

&lt;p&gt;RFC 7208 puts the ceiling at ten DNS-querying terms per evaluation, and going over it produces a &lt;code&gt;permerror&lt;/code&gt;. That's a hard failure, not a soft one, and it isn't something you'll notice unless you're reading your reports. Three or four mainstream providers is enough to get uncomfortably close.&lt;/p&gt;

&lt;p&gt;There's also a recommended limit of two "void" lookups — queries that come back empty, or for a name that doesn't exist. Which is a good reason to clear out includes for services you stopped using two migrations ago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check how your SPF record ends
&lt;/h2&gt;

&lt;p&gt;This one takes ten seconds, and it's the first thing I'd look at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v=spf1 include:_spf.google.com all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That record passes every sender on the internet.&lt;/p&gt;

&lt;p&gt;A mechanism with no qualifier defaults to &lt;code&gt;+&lt;/code&gt;, so a bare &lt;code&gt;all&lt;/code&gt; means &lt;code&gt;+all&lt;/code&gt; — pass. It's one missing character, the record looks perfectly tidy, and nothing anywhere warns you. &lt;code&gt;~all&lt;/code&gt; is softfail and &lt;code&gt;-all&lt;/code&gt; is fail; one of those is almost certainly what was intended.&lt;/p&gt;

&lt;p&gt;While you're in there: if you added a second &lt;code&gt;v=spf1&lt;/code&gt; record when you onboarded a new provider, the two don't combine. More than one SPF record at the same name is a &lt;code&gt;permerror&lt;/code&gt;, and the fix is to merge the mechanisms into a single record.&lt;/p&gt;

&lt;h2&gt;
  
  
  DKIM: "not found" often means "not found by this checker"
&lt;/h2&gt;

&lt;p&gt;If a checking tool tells you DKIM is missing, it's worth a second look before you go and re-key anything.&lt;/p&gt;

&lt;p&gt;DKIM keys live at names called selectors, and there's no standard lookup that lists them. So a checker has to guess: it tries a handful of common selectors and reports what comes back. If yours isn't on its list, you get an empty result and a record that is perfectly healthy.&lt;/p&gt;

&lt;p&gt;A real signed message settles it in seconds. Find the &lt;code&gt;DKIM-Signature&lt;/code&gt; header on a message you sent externally — &lt;code&gt;s=&lt;/code&gt; is the selector, &lt;code&gt;d=&lt;/code&gt; is the signing domain. That domain often isn't the one in the From field, particularly when a marketing platform signs on your behalf, and that's expected rather than a problem.&lt;/p&gt;

&lt;p&gt;Two things worth filing away. Finding a key doesn't prove your messages are actually being signed correctly — it only proves the key is published. And a record with an empty &lt;code&gt;p=&lt;/code&gt; value means the key has been &lt;em&gt;revoked&lt;/em&gt;, not that it's missing. The record is there; the key just isn't usable any more.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you only do three things
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Look at a real message header&lt;/strong&gt; from your own domain, on something that went to an external recipient. It'll hand you your DKIM selector and signing domain straight away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the end of your SPF record&lt;/strong&gt;, and count the includes while you're there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read a week of DMARC aggregate reports before you tighten anything.&lt;/strong&gt; They're the only thing that tells you who is genuinely sending as you, and there's nearly always one legitimate sender nobody remembered.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built a free checker for this, mostly because I got tired of tools reporting "DKIM: missing" when they simply didn't know my selector: &lt;a href="https://stashgrid.site/emailcheck/" rel="noopener noreferrer"&gt;stashgrid.site/emailcheck&lt;/a&gt;. It reads the public SPF, DKIM, DMARC, MTA-STS, TLS-RPT and DNSSEC records for a domain and explains each result in plain English. It runs in the browser, there's no sign-up, and nothing is stored. Treat it as a starting point rather than an audit — the fuller picture comes from your reports and your own message headers.&lt;/p&gt;

&lt;p&gt;One last thing, worth saying plainly: these records stop people sending mail &lt;em&gt;as your domain&lt;/em&gt;. They don't filter what arrives in your inbox, and they can't stop someone registering a domain that merely looks like yours and sending from that instead. Different problem, different tools.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://www.rfc-editor.org/rfc/rfc9989.html" rel="noopener noreferrer"&gt;RFC 9989&lt;/a&gt; (DMARC), &lt;a href="https://www.rfc-editor.org/rfc/rfc7208.html" rel="noopener noreferrer"&gt;RFC 7208&lt;/a&gt; (SPF), &lt;a href="https://www.rfc-editor.org/info/rfc6376/" rel="noopener noreferrer"&gt;RFC 6376&lt;/a&gt; (DKIM).&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>dns</category>
      <category>email</category>
    </item>
  </channel>
</rss>
