<?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: nelson-digital</title>
    <description>The latest articles on DEV Community by nelson-digital (@nelsondigital).</description>
    <link>https://dev.to/nelsondigital</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%2F4088764%2F19a61054-6115-4172-b5ca-b4237d7ec3a9.png</url>
      <title>DEV Community: nelson-digital</title>
      <link>https://dev.to/nelsondigital</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nelsondigital"/>
    <language>en</language>
    <item>
      <title>403 doesn't mean dead: four ways a link checker lies to you</title>
      <dc:creator>nelson-digital</dc:creator>
      <pubDate>Fri, 21 Aug 2026 19:15:00 +0000</pubDate>
      <link>https://dev.to/nelsondigital/403-doesnt-mean-dead-four-ways-a-link-checker-lies-to-you-23dj</link>
      <guid>https://dev.to/nelsondigital/403-doesnt-mean-dead-four-ways-a-link-checker-lies-to-you-23dj</guid>
      <description>&lt;p&gt;tags: node, webdev, showdev, testing&lt;/p&gt;

&lt;p&gt;I had a list of 115 domains and one question: which of these are still alive?&lt;/p&gt;

&lt;p&gt;The naive version is ten lines. Fetch each one, check the status code, done. I wrote that, ran it, and it got &lt;strong&gt;12 of the 115 wrong&lt;/strong&gt; — in both directions. Here is what it missed, because every one of these is a different failure wearing the same disguise.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A 403 is almost never a dead site
&lt;/h2&gt;

&lt;p&gt;Cloudflare's challenge page returns HTTP 403 to anything that doesn't look like a browser. The body is about 23 words and the title is &lt;code&gt;Just a moment...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My first pass called every one of those dead. That threw away eight perfectly good domains, including Sooper Articles, The Free Library, Diigo, HubPages, Typepad and the SitePoint and Joomla forums. All alive. All reachable by a human. All reported as gone.&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;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sr"&gt;/just a moment|attention required/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;LIVE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;note&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bot wall, host is up&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;The trade-off is real: a genuinely dead host that happens to 403 now reads as alive. For my use case that is the cheaper mistake by a wide margin. Deleting a working entry costs me something real. Keeping a dead one costs five minutes when someone tries it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Parked domains return a cheerful 200
&lt;/h2&gt;

&lt;p&gt;A domain that lapsed and got picked up by a parking service is &lt;em&gt;up&lt;/em&gt;. It resolves, it serves HTML, it returns 200. It is also completely useless.&lt;/p&gt;

&lt;p&gt;My first attempt matched on parking-service brand names — &lt;code&gt;godaddy&lt;/code&gt;, &lt;code&gt;sedo&lt;/code&gt;, &lt;code&gt;bodis&lt;/code&gt;, &lt;code&gt;namecheap&lt;/code&gt;. That is the obvious approach and it is wrong, because plenty of live sites load a script, a font or an analytics beacon from one of those hosts.&lt;/p&gt;

&lt;p&gt;It confidently reported that &lt;strong&gt;Zotero was for sale&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to match only phrases that appear on a genuine sale page and nowhere else:&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;FOR_SALE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;this domain &lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;name &lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;is for sale|buy this domain|make an offer on this domain&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Narrow and boring beats clever here. The other parking pattern worth catching is the JS bounce, where the page is a stub that redirects to &lt;code&gt;/lander&lt;/code&gt; on load.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Byte length is not a proxy for content
&lt;/h2&gt;

&lt;p&gt;I tried "is the response under N bytes" as a shortcut for "this page is empty". It fails immediately on anything client-rendered — an SPA ships a big HTML file with a &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt; and nothing else.&lt;/p&gt;

&lt;p&gt;Strip the scripts and the markup first, then count what a human would actually read:&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;wordCount&lt;/span&gt; &lt;span class="o"&gt;=&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;html&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;/&amp;lt;script&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;script&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;style&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;style&amp;gt;/gi&lt;/span&gt;&lt;span class="p"&gt;,&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &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;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;\s&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="s2"&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under about 30 words and there is no page there, whatever the byte count says.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A connection error is several different problems
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fetch&lt;/code&gt; throwing tells you almost nothing on its own. These all land in the same catch block and mean very different things:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Code&lt;/th&gt;
&lt;th&gt;What it actually means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENOTFOUND&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The host does not resolve. Genuinely gone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ECONNREFUSED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DNS resolves, nothing is listening.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CERT_HAS_EXPIRED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The site works fine in a browser after a click-through.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ERR_TLS_CERT_ALTNAME_INVALID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Certificate does not cover this hostname. Often a live site behind a misconfigured proxy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AbortError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Slow, not dead. Worth a retry before you write it off.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lumping the last three in with the first is how you delete working entries.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I ended up with
&lt;/h2&gt;

&lt;p&gt;Four verdicts instead of a boolean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LIVE&lt;/strong&gt; — real page, or a bot wall, which still means a human can reach it&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DEAD&lt;/strong&gt; — does not resolve, refused, expired cert, 4xx or 5xx&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PARKED&lt;/strong&gt; — for sale, or bouncing to a parking lander&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EMPTY&lt;/strong&gt; — 200 with essentially nothing rendered
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;node check.mjs sites.txt
&lt;span class="go"&gt;LIVE   sooperarticles.com    bot wall, could not read the page but the host is up
LIVE   zotero.org
DEAD   some-old-directory.com   host does not resolve

3 checked  LIVE 2  DEAD 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No dependencies, Node 18+, MIT. It's here if it's useful: &lt;a href="https://github.com/damani-pixel/citation-check" rel="noopener noreferrer"&gt;github.com/damani-pixel/citation-check&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The broader lesson, and the reason I wrote this up rather than just fixing it quietly: my first version was &lt;em&gt;confidently&lt;/em&gt; wrong. It printed a clean report with no errors and no warnings, and a third of the failures in it were mine. If you are writing anything that classifies the outside world, the interesting work is not the happy path — it is enumerating the ways the outside world lies to you.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>testing</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
