<?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: ricco020</title>
    <description>The latest articles on DEV Community by ricco020 (@ricco020).</description>
    <link>https://dev.to/ricco020</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%2F3960216%2Fbc01eb94-de65-41c2-9767-966a157976ae.jpeg</url>
      <title>DEV Community: ricco020</title>
      <link>https://dev.to/ricco020</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ricco020"/>
    <language>en</language>
    <item>
      <title>I changed a Play Store title in 4 languages: brand rank went up, generic rank did not move</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Fri, 13 Nov 2026 21:12:45 +0000</pubDate>
      <link>https://dev.to/ricco020/i-changed-a-play-store-title-in-4-languages-brand-rank-went-up-generic-rank-did-not-move-1pf8</link>
      <guid>https://dev.to/ricco020/i-changed-a-play-store-title-in-4-languages-brand-rank-went-up-generic-rank-did-not-move-1pf8</guid>
      <description>&lt;p&gt;We changed the Google Play title of a small Android app (a reverse chord finder for guitar) in four languages at once: Danish, Italian, Russian and Swedish. Two days later we had to decide: keep the new titles or roll them back.&lt;/p&gt;

&lt;p&gt;The tempting reading was "the app now shows up in all four countries, it worked". That reading was wrong, or at least it answered a different question. This post shows the raw numbers from that verdict (measured 3 November 2026) plus a second one on short descriptions (measured 4 November 2026), and the method we now use so that a rank change is not mistaken for demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change
&lt;/h2&gt;

&lt;p&gt;New titles, one per locale, all built as &lt;code&gt;&amp;lt;local generic term&amp;gt;: Chord Finder&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Locale&lt;/th&gt;
&lt;th&gt;New title&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DK&lt;/td&gt;
&lt;td&gt;da&lt;/td&gt;
&lt;td&gt;Guitar Akkorder: Chord Finder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IT&lt;/td&gt;
&lt;td&gt;it&lt;/td&gt;
&lt;td&gt;Accordi Chitarra: Chord Finder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RU&lt;/td&gt;
&lt;td&gt;ru&lt;/td&gt;
&lt;td&gt;Аккорды на гитаре Chord Finder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SE&lt;/td&gt;
&lt;td&gt;sv&lt;/td&gt;
&lt;td&gt;Gitarr Ackord: Chord Finder&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The idea: keep the brand-ish part ("Chord Finder"), add the generic phrase people type in their language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that are NOT the same measurement
&lt;/h2&gt;

&lt;p&gt;Before reading any number, separate three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Brand rank&lt;/strong&gt;: when someone types a name close to ours ("Reverse Chord Finder"), are we in the results, and where? This mostly measures indexing and title matching. It says little about demand: nobody types a name they have never seen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generic rank&lt;/strong&gt;: when someone types the phrase they actually need ("accordi chitarra", "gitarr ackord"), where are we? This is the one that can bring new users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installs&lt;/strong&gt;: did more people install? This is the only one that pays, and it is the noisiest.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A title change can move (1) a lot, (2) not at all, and (3) by an amount you cannot read in two days. That is exactly what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The J+2 numbers (3 November 2026)
&lt;/h2&gt;

&lt;p&gt;Measured on the public Play search, one request per query and country, top 30 results (Sweden returned only 9 results on the exact title query, Denmark 50).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact new title as a query&lt;/strong&gt;: absent in 4 of 4 countries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generic query (the local phrase alone)&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Our rank (top 30)&lt;/th&gt;
&lt;th&gt;Top result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DK&lt;/td&gt;
&lt;td&gt;guitar akkorder&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;com.ultimateguitar.tabs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IT&lt;/td&gt;
&lt;td&gt;accordi chitarra&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;com.ovelin.guitartuna&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RU&lt;/td&gt;
&lt;td&gt;аккорды на гитаре&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;com.amdm.amdm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SE&lt;/td&gt;
&lt;td&gt;gitarr ackord&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;com.ultimateguitar.tabs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Brand query "Reverse Chord Finder"&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Rank before the change&lt;/th&gt;
&lt;th&gt;Rank at J+2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DK&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IT&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RU&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SE&lt;/td&gt;
&lt;td&gt;absent&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So: 0 of 4 generic wins, 4 of 4 brand wins. The verdict was "keep the titles" (they did no harm and fixed brand findability in four countries where we were invisible even on our own name), but the honest summary is: &lt;strong&gt;this change made us findable, it did not make us discoverable.&lt;/strong&gt; The generic phrases are held by large, established apps (Ultimate Guitar, GuitarTuna, Chordify). A title alone does not move a small app past them.&lt;/p&gt;

&lt;h2&gt;
  
  
  A second verdict, same method (4 November 2026)
&lt;/h2&gt;

&lt;p&gt;We also rewrote the short description in five locales to carry the generic term. At J+2, generic queries: 0 of 10 in the top 30 (ES, RO, FR, DE, BR, two phrases each). Brand query:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Country&lt;/th&gt;
&lt;th&gt;Brand rank before&lt;/th&gt;
&lt;th&gt;Brand rank at J+2&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ES&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;rolled back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DE&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;rolled back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FR&lt;/td&gt;
&lt;td&gt;not in this snapshot&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RO&lt;/td&gt;
&lt;td&gt;not in this snapshot&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BR&lt;/td&gt;
&lt;td&gt;not in this snapshot&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;kept&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two locales lost brand rank with zero generic gain, so they were restored to the previous text. Without the separation above, an average "brand rank across 5 countries" would have hidden that two of them got worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why installs were not part of the verdict
&lt;/h2&gt;

&lt;p&gt;We deliberately did not call the change on installs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same week, a new app version with a different paywall went out (3 November). Any install or revenue movement in those four countries mixes both changes.&lt;/li&gt;
&lt;li&gt;Per-country daily installs for a small app are tiny. Two days of tiny numbers cannot separate a real effect from noise.&lt;/li&gt;
&lt;li&gt;Play Console install reports lag. A J+2 verdict on installs reads data that is not complete yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the J+2 verdict uses ranks only, and installs get their own check at J+14 with a written threshold. If you only have one change running, you can be bolder. If you have two, say so in the verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method, reproducible
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the verdict rule before publishing.&lt;/strong&gt; Ours: keep if the generic rank enters the top 30 in at least one country, or if the brand rank improves without any generic loss. Roll back a locale if the brand rank drops by 3+ positions with no generic gain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot "before" for three query types&lt;/strong&gt; per locale: exact new title, generic phrase(s), brand name. Store raw JSON: &lt;code&gt;{country, locale, query, rank, n_results, top3}&lt;/code&gt;. &lt;code&gt;n_results&lt;/code&gt; matters: "absent" from 9 results is not "absent" from 50.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Publish, then check that the store serves the new text&lt;/strong&gt; before starting the clock. A title saved in the console is not a title in search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure at J+2 with the same script, same countries, same depth.&lt;/strong&gt; Compare per locale, never as an average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report the three axes separately&lt;/strong&gt;: brand, generic, installs. A row with "brand up, generic absent" is a findability fix, not a growth result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installs at J+14&lt;/strong&gt;, with any other change of the period listed next to the number.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A minimal record per query looks 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="nl"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"IT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"l"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"it"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"q"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"accordi chitarra"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;"rang"&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="nl"&gt;"n"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="nl"&gt;"top3"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"com.ovelin.guitartuna"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"net.chordify.chordify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"com.myproj.chordchartgenerator"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="nl"&gt;"etat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"ABSENT"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keeping &lt;code&gt;top3&lt;/code&gt; turned out to be useful: it tells you whether a generic query is even winnable (three apps with huge review counts at the top usually means no).&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would do differently
&lt;/h2&gt;

&lt;p&gt;Spend the title on a query the app can actually win (long-tail, with weak incumbents) rather than on the head term of each language. In Korean, the same app ranks 1st on a narrow "reverse chord search" phrase. That is where a title earns installs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I work on the app above and on &lt;a href="https://z-ax.com/en/aso-case-study/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=aso_measurement_0511" rel="noopener noreferrer"&gt;ZAX&lt;/a&gt;, which sells store listing rewrites. The linked page is our longer case study with dated ranks for other apps, same method.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>aso</category>
      <category>mobile</category>
      <category>analytics</category>
    </item>
    <item>
      <title>DoH costs one TLS handshake, then almost nothing: 45 timed queries to Cloudflare, Google and Quad9</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sun, 01 Nov 2026 22:07:29 +0000</pubDate>
      <link>https://dev.to/ricco020/doh-costs-one-tls-handshake-then-almost-nothing-45-timed-queries-to-cloudflare-google-and-quad9-14h6</link>
      <guid>https://dev.to/ricco020/doh-costs-one-tls-handshake-then-almost-nothing-45-timed-queries-to-cloudflare-google-and-quad9-14h6</guid>
      <description>&lt;p&gt;Most DNS-over-HTTPS guides say the overhead is "negligible after warmup" and stop there. I wanted the number, so tonight I timed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vantage point: one VPS in a datacenter (Contabo, AS51167, Lauterbourg, France), 1 Nov 2026, 22:0x UTC. A datacenter sits much closer to these resolvers than a home connection, so read the absolute values as a floor, not as what you will see on Wi-Fi.&lt;/li&gt;
&lt;li&gt;Resolvers: Cloudflare (&lt;code&gt;cloudflare-dns.com/dns-query&lt;/code&gt;), Google (&lt;code&gt;dns.google/dns-query&lt;/code&gt;), Quad9 (&lt;code&gt;dns.quad9.net/dns-query&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Query: &lt;code&gt;example.com A&lt;/code&gt;, RFC 8484 wire format over HTTP/2, curl 8.5.0.&lt;/li&gt;
&lt;li&gt;3 runs per resolver. Each run opens one connection and sends 5 queries on it, so query 1 pays for TCP + TLS and queries 2 to 5 reuse the connection. 45 queries in total.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;Q&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"AAABAAABAAAAAAAAB2V4YW1wbGUDY29tAAABAAE"&lt;/span&gt;   &lt;span class="c"&gt;# example.com A, base64url&lt;/span&gt;
&lt;span class="nv"&gt;u&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://cloudflare-dns.com/dns-query?dns=&lt;/span&gt;&lt;span class="nv"&gt;$Q&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--http2&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'accept: application/dns-message'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"total=%{time_total} connect=%{time_connect} tls=%{time_appconnect}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resolver&lt;/th&gt;
&lt;th&gt;1st query (3 runs)&lt;/th&gt;
&lt;th&gt;TLS done at&lt;/th&gt;
&lt;th&gt;Queries 2 to 5, median (12 values)&lt;/th&gt;
&lt;th&gt;Plain UDP, &lt;code&gt;dig&lt;/code&gt; (5 values)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;47.6 / 28.3 / 28.9 ms&lt;/td&gt;
&lt;td&gt;40.0 / 23.8 / 24.1 ms&lt;/td&gt;
&lt;td&gt;4.8 ms&lt;/td&gt;
&lt;td&gt;2, 3, 3, 3, 4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;36.1 / 39.0 / 49.9 ms&lt;/td&gt;
&lt;td&gt;26.9 / 26.7 / 41.2 ms&lt;/td&gt;
&lt;td&gt;5.6 ms&lt;/td&gt;
&lt;td&gt;4, 5, 7, 8, 18 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quad9&lt;/td&gt;
&lt;td&gt;39.8 / 38.0 / 52.4 ms&lt;/td&gt;
&lt;td&gt;32.2 / 30.4 / 37.5 ms&lt;/td&gt;
&lt;td&gt;7.7 ms&lt;/td&gt;
&lt;td&gt;2, 2, 2, 12, 13 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What the numbers say:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The first query costs 28 to 52 ms, and almost all of it is the handshake.&lt;/strong&gt; TCP connect is 4 to 16 ms; the TLS handshake finishes 24 to 41 ms after the start. The DNS answer itself is the small remainder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Once the connection is open, a DoH query costs 4 to 15 ms&lt;/strong&gt;, a few milliseconds above plain UDP to the same provider. The "negligible after warmup" claim holds, on this path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quad9's third run settled at 15 ms per warm query&lt;/strong&gt; instead of 7.7. Same connection, same query: most likely a different anycast node or a longer route for that connection. One run is not enough to rank resolvers on speed; three already show the spread.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dig&lt;/code&gt; query time and curl &lt;code&gt;time_total&lt;/code&gt; do not measure exactly the same thing (curl also counts HTTP framing and its own processing). Compare inside a column, not across columns.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The practical consequence
&lt;/h2&gt;

&lt;p&gt;The cost of DoH is per &lt;strong&gt;connection&lt;/strong&gt;, not per query. A browser keeps its DoH connection open, so you pay the 30 to 50 ms once and then a few ms per lookup. Anything that keeps tearing that connection down (aggressive power saving, a flaky network, a VPN client that resets sockets on reconnect) pays the handshake again each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  One filtering check, while I was there
&lt;/h2&gt;

&lt;p&gt;Quad9 publishes &lt;code&gt;isitblocked.org&lt;/code&gt; as a canary for its blocklist. From the same VPS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dig @9.9.9.9 isitblocked.org   -&amp;gt; NXDOMAIN (blocked)
dig @1.1.1.1 isitblocked.org   -&amp;gt; 74.208.236.124
dig @1.1.1.2 isitblocked.org   -&amp;gt; 74.208.236.124
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the filter is live on Quad9's plain resolver. That &lt;code&gt;1.1.1.2&lt;/code&gt; (Cloudflare's malware-filtering resolver) does not block it says nothing about which blocklist is better: it is Quad9's own test domain, not malware.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits
&lt;/h2&gt;

&lt;p&gt;One vantage point, one domain already cached at every resolver, one evening. No home ISP path, no mobile network, no HTTP/3. If you rerun the snippet from home, I would expect the handshake to grow far more than the warm queries.&lt;/p&gt;

&lt;p&gt;If you are setting DoH up rather than benchmarking it, I keep a browser-by-browser guide on my own site (Chrome, Edge, Firefox, Safari, iOS, Android) that also covers the most common leak, browser DoH silently bypassing a VPN's resolver: &lt;a href="https://www.anonymflow.com/en/blog/dns-over-https-browser-setup-2026" rel="noopener noreferrer"&gt;DNS over HTTPS setup per browser&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>privacy</category>
      <category>networking</category>
      <category>security</category>
    </item>
    <item>
      <title>Wrong key or wrong short ID? Xray REALITY gives the same error for both</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sun, 01 Nov 2026 21:50:33 +0000</pubDate>
      <link>https://dev.to/ricco020/wrong-key-or-wrong-short-id-xray-reality-gives-the-same-error-for-both-4303</link>
      <guid>https://dev.to/ricco020/wrong-key-or-wrong-short-id-xray-reality-gives-the-same-error-for-both-4303</guid>
      <description>&lt;p&gt;I maintain VLESS + REALITY config templates for people running their own Xray server, and tonight I ran a small fault-injection test on Xray-core 26.10.31 to answer one question: when a REALITY client is misconfigured, what does each side actually tell you?&lt;/p&gt;

&lt;p&gt;Short answer: the server tells you nothing, and two different mistakes produce the exact same client error.&lt;/p&gt;

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

&lt;p&gt;Everything ran on one Linux box, loopback only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Xray 26.10.31 (a4a3dff, go1.27.3), server inbound VLESS + REALITY + Vision, target &lt;code&gt;www.microsoft.com&lt;/code&gt;, server log level &lt;code&gt;warning&lt;/code&gt; (the usual production setting).&lt;/li&gt;
&lt;li&gt;A client config with a local SOCKS5 inbound and an HTTP inbound, &lt;code&gt;fingerprint: chrome&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fresh keys from &lt;code&gt;xray x25519&lt;/code&gt;, UUIDs from &lt;code&gt;xray uuid&lt;/code&gt;, a short ID from &lt;code&gt;openssl rand -hex 8&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Test request: &lt;code&gt;curl --socks5-hostname 127.0.0.1:&amp;lt;port&amp;gt; https://www.cloudflare.com/cdn-cgi/trace&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Baseline first. Both files pass &lt;code&gt;xray run -test&lt;/code&gt; ("Configuration OK."), the correct client returns &lt;strong&gt;HTTP 200&lt;/strong&gt; through SOCKS5 and through the HTTP inbound, and a plain TLS probe to the server port (&lt;code&gt;openssl s_client -servername www.microsoft.com&lt;/code&gt;) gets the real Microsoft certificate (&lt;code&gt;CN = www.microsoft.com&lt;/code&gt;). That last part is the whole point of REALITY: a prober who does not hold the key just sees the cover site.&lt;/p&gt;

&lt;p&gt;Then I started three broken clients against the same server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client mistake&lt;/th&gt;
&lt;th&gt;curl result&lt;/th&gt;
&lt;th&gt;Client log&lt;/th&gt;
&lt;th&gt;Server log (warning)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wrong public key (&lt;code&gt;password&lt;/code&gt; from another keypair)&lt;/td&gt;
&lt;td&gt;fails after ~2.1 s, &lt;code&gt;SSL_ERROR_SYSCALL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REALITY: received real certificate (potential MITM or redirection)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;shortId&lt;/code&gt; not in the server list&lt;/td&gt;
&lt;td&gt;fails after ~2.1 s, &lt;code&gt;SSL_ERROR_SYSCALL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REALITY: received real certificate (potential MITM or redirection)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;serverName&lt;/code&gt; not in &lt;code&gt;serverNames&lt;/code&gt; (&lt;code&gt;www.apple.com&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;fails after ~2.2 s, &lt;code&gt;SSL_ERROR_SYSCALL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;only &lt;code&gt;accepted tcp:www.cloudflare.com:443 [socks-in &amp;gt;&amp;gt; proxy]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things I did not expect before running it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A wrong key and a wrong short ID look identical.&lt;/strong&gt; In both cases the server does not recognise the client as one of its own, so it behaves as a transparent relay to the target, the client receives Microsoft's genuine certificate, and Xray reports a possible MITM. The message is accurate from the client's point of view, but it sends you looking at your network when the problem is a copy-paste. If you see &lt;code&gt;received real certificate&lt;/code&gt;, check the public key and the short ID first, in that order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A wrong SNI is even quieter.&lt;/strong&gt; No REALITY error at all on the client, just a connection accepted locally and a failed TLS session downstream. If your client logs show traffic being accepted and nothing coming back, compare &lt;code&gt;serverName&lt;/code&gt; with the server's &lt;code&gt;serverNames&lt;/code&gt; array character by character.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The server says nothing at &lt;code&gt;warning&lt;/code&gt;.&lt;/strong&gt; None of the three failures produced a single line on the server. That is by design (a REALITY server is supposed to look like it is not there), but it means "check the server logs" is useless advice for client-side mistakes unless you temporarily raise the log level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two warnings worth reading
&lt;/h2&gt;

&lt;p&gt;Xray 26.10.31 now prints these at startup when you choose them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REALITY: Choosing "www.microsoft.com" as the target will increase the likelihood of your server's IP being blocked by the GFW
REALITY: Listening on non-443 ports will increase the likelihood of your server's IP being blocked by the GFW
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first one is new to me in a release build and contradicts a lot of older tutorials that use &lt;code&gt;www.microsoft.com&lt;/code&gt; as the default &lt;code&gt;dest&lt;/code&gt;. Pick a target that is plausible for your server's network neighbourhood and supports TLS 1.3 and H2.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field name changed
&lt;/h2&gt;

&lt;p&gt;In the client &lt;code&gt;realitySettings&lt;/code&gt;, current Xray uses &lt;code&gt;password&lt;/code&gt; for what everyone still calls the public key. &lt;code&gt;xray x25519&lt;/code&gt; prints it as &lt;code&gt;Password (PublicKey): ...&lt;/code&gt;. Templates that still say &lt;code&gt;publicKey&lt;/code&gt; keep working for now, but if you diff your config against a recent example and see &lt;code&gt;password&lt;/code&gt;, it is the same value, not a new secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  One client I could not make work
&lt;/h2&gt;

&lt;p&gt;I also tried a sing-box 1.14.4 client against the same Xray 26.10.31 server, with &lt;code&gt;chrome&lt;/code&gt;, &lt;code&gt;firefox&lt;/code&gt; and &lt;code&gt;safari&lt;/code&gt; fingerprints. All three failed with &lt;code&gt;reality verification failed&lt;/code&gt;, while the Xray client with the same values worked. I have not dug into the cause, so treat it as "test your exact client and server versions together", not as a verdict on sing-box.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 3-line checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;xray run -test -c config.json&lt;/code&gt; on both sides. It catches placeholders and malformed keys immediately (&lt;code&gt;invalid "privateKey"&lt;/code&gt;), not logic errors.&lt;/li&gt;
&lt;li&gt;Run the server and the client on one machine on a spare port before touching the VPS firewall. If loopback works, the remaining problem is network, not config.&lt;/li&gt;
&lt;li&gt;If it fails with &lt;code&gt;received real certificate&lt;/code&gt;, the key or the short ID is wrong. If it fails silently, the SNI is wrong.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I keep a longer walkthrough of VMess vs VLESS, REALITY and the transport options, with the full server and client JSON, here: &lt;a href="https://www.vpnsmith.com/en/blog/v2ray-vmess-vless-setup-2026" rel="noopener noreferrer"&gt;V2Ray, VMess and VLESS setup guide&lt;/a&gt;. If you just want the keys, short ID and share link generated in the browser, there is a free &lt;a href="https://www.vpnsmith.com/en/tools/vless-reality-config-generator" rel="noopener noreferrer"&gt;VLESS REALITY config generator&lt;/a&gt; on the same site.&lt;/p&gt;

</description>
      <category>networking</category>
      <category>selfhosted</category>
      <category>linux</category>
      <category>privacy</category>
    </item>
    <item>
      <title>I checked what 16 web hosts serve on their own homepage: HSTS, HTTP/3, IPv6, TLS 1.3</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sat, 31 Oct 2026 18:23:41 +0000</pubDate>
      <link>https://dev.to/ricco020/i-checked-what-16-web-hosts-serve-on-their-own-homepage-hsts-http3-ipv6-tls-13-5175</link>
      <guid>https://dev.to/ricco020/i-checked-what-16-web-hosts-serve-on-their-own-homepage-hsts-http3-ipv6-tls-13-5175</guid>
      <description>&lt;p&gt;I write hosting comparisons, and most of them repeat what the hosts say about themselves: free SSL, HTTP/3, "modern stack". So I checked what the hosts actually serve on &lt;strong&gt;their own homepage&lt;/strong&gt;. One &lt;code&gt;curl -sL&lt;/code&gt; per site, from one VPS in Germany, on 31 October 2026 at 18:22 UTC.&lt;/p&gt;

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

&lt;p&gt;For the final response after redirects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Strict-Transport-Security&lt;/code&gt; header present (HSTS)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Content-Security-Policy&lt;/code&gt; header present (not its quality, only its presence)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Alt-Svc&lt;/code&gt; advertising &lt;code&gt;h3&lt;/code&gt; (HTTP/3)&lt;/li&gt;
&lt;li&gt;an &lt;code&gt;AAAA&lt;/code&gt; record on the final hostname (IPv6)&lt;/li&gt;
&lt;li&gt;a TLS 1.3 handshake accepted (&lt;code&gt;openssl s_client -tls1_3&lt;/code&gt;)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sL&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-D&lt;/span&gt; hdr.txt &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"Mozilla/5.0 ..."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{url_effective} %{time_starttransfer}"&lt;/span&gt; https://&lt;span class="nv"&gt;$host&lt;/span&gt;/
dig +short AAAA &lt;span class="nv"&gt;$final_host&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; | openssl s_client &lt;span class="nt"&gt;-connect&lt;/span&gt; &lt;span class="nv"&gt;$final_host&lt;/span&gt;:443 &lt;span class="nt"&gt;-servername&lt;/span&gt; &lt;span class="nv"&gt;$final_host&lt;/span&gt; &lt;span class="nt"&gt;-tls1_3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Results: 16 hosts, 11 measurable
&lt;/h2&gt;

&lt;p&gt;Five answered &lt;strong&gt;403&lt;/strong&gt; to my request (LWS, Contabo, HostPapa, Bluehost, Scaleway). A 403 page from a bot filter tells you nothing about the real headers, so I count them as &lt;strong&gt;not measured&lt;/strong&gt;, not as "no".&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host (final hostname)&lt;/th&gt;
&lt;th&gt;HSTS&lt;/th&gt;
&lt;th&gt;CSP&lt;/th&gt;
&lt;th&gt;HTTP/3&lt;/th&gt;
&lt;th&gt;IPv6&lt;/th&gt;
&lt;th&gt;TLS 1.3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;hetzner.com&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dreamhost.com&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ultahost.com&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;infomaniak.com&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hosting.com (from a2hosting.com)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;siteground.com&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;planethoster.com&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;hostinger.com (from hostinger.fr)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ionos.fr&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ovhcloud.com&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;o2switch.fr&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Counts on the 11: HSTS 8, CSP 7, HTTP/3 5, IPv6 6, TLS 1.3 10.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I did not expect
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;ionos.fr refuses TLS 1.3.&lt;/strong&gt; The handshake ends with &lt;code&gt;tlsv1 alert protocol version&lt;/code&gt;; without the flag it negotiates TLS 1.2 (&lt;code&gt;ECDHE-RSA-AES256-GCM-SHA384&lt;/code&gt;). That is still a secure protocol, but it is the only one of the 11 stuck there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two large French hosts send no HSTS on their homepage&lt;/strong&gt;: o2switch and OVHcloud. o2switch also has no IPv6 record, no HTTP/3 and no CSP: it scored zero on four of five checks while being one of the most recommended shared hosts in France.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"HTTP/3" in a feature list and HTTP/3 on the vendor's own site are different things.&lt;/strong&gt; Six of the 11 do not advertise it on their homepage.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this does NOT tell you
&lt;/h2&gt;

&lt;p&gt;This is the &lt;strong&gt;marketing site&lt;/strong&gt;, not the servers you rent. A host can run a dated homepage and give customers HTTP/3, IPv6 and TLS 1.3 by default, or the reverse. The homepage is often behind a CDN the customer plan never touches. So do not pick a host from this table. Use it as a question to ask: if the plan page says "HTTP/3 included", test a customer site on that plan, the same way, before you trust the claim.&lt;/p&gt;

&lt;p&gt;I compare two of these hosts (o2switch and PlanetHoster, which differ on two of the five checks here, HSTS and CSP) on the things that do matter for customers, price over 24 months, renewal and limits, in this &lt;a href="https://www.hostpilote.com/en/blog/o2switch-vs-planethoster-comparison" rel="noopener noreferrer"&gt;o2switch vs PlanetHoster comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;One request per host, and stop on a 403: hammering a bot filter will not make it answer. Results move: a host can enable HSTS tomorrow. This table is dated 31 October 2026, 18:22 UTC, and it is a snapshot, not a ranking.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>hosting</category>
      <category>devops</category>
    </item>
    <item>
      <title>I checked 40 sites for /.well-known/passkey-endpoints. 11 of the 34 I could measure publish it</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sat, 31 Oct 2026 16:53:05 +0000</pubDate>
      <link>https://dev.to/ricco020/i-checked-40-sites-for-well-knownpasskey-endpoints-11-of-the-34-i-could-measure-publish-it-4g64</link>
      <guid>https://dev.to/ricco020/i-checked-40-sites-for-well-knownpasskey-endpoints-11-of-the-34-i-could-measure-publish-it-4g64</guid>
      <description>&lt;p&gt;Passkeys have a small discovery problem. A password manager that wants to say "this site supports passkeys, create one now" has to guess where the enrollment page lives. The W3C WebAppSec draft "A Well-Known URL for Passkey Endpoints" fixes that: a site serves a JSON file at &lt;code&gt;/.well-known/passkey-endpoints&lt;/code&gt; with an &lt;code&gt;enroll&lt;/code&gt; URL and a &lt;code&gt;manage&lt;/code&gt; URL.&lt;/p&gt;

&lt;p&gt;I wanted to know how many large sites actually publish it, so I checked 40 of them today (31 October 2026, 17:50 UTC+1) with one &lt;code&gt;curl&lt;/code&gt; per domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 12 &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;desktop Chrome UA&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; body &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code} %{content_type}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;/.well-known/passkey-endpoints"&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import json; print(sorted(json.load(open('body')).keys()))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A domain counts as supporting the file only if the final response is 200 &lt;strong&gt;and&lt;/strong&gt; the body parses as JSON with at least one of the two keys. A 200 that returns an HTML page is a soft 404, not support.&lt;/p&gt;

&lt;p&gt;One caveat matters: the requests came from a datacenter server. Six domains answered with a bot wall (403) or a broken connection, so I count them as &lt;strong&gt;not measured&lt;/strong&gt;, not as "no".&lt;/p&gt;

&lt;h2&gt;
  
  
  Result: 11 of 34 measurable domains
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Domain&lt;/th&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;Keys&lt;/th&gt;
&lt;th&gt;Content-Type&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;amazon.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paypal.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;text/plain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ebay.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dropbox.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shopify.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;adobe.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;(none)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tiktok.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll&lt;/td&gt;
&lt;td&gt;application/octet-stream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kayak.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;target.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uber.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;application/json&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;coinbase.com&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;enroll, manage&lt;/td&gt;
&lt;td&gt;text/plain&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;No file (404, or HTML on a 200):&lt;/strong&gt; google.com, accounts.google.com, microsoft.com, login.microsoftonline.com, apple.com, github.com, facebook.com (400), x.com and linkedin.com (200 but HTML), nintendo.com, ubisoft.com, yahoo.com, proton.me, 1password.com, dashlane.com, cloudflare.com, stripe.com, walmart.com, hubspot.com, mercadolibre.com.ar, zoho.com, docusign.com, discord.com.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not measured from a datacenter IP:&lt;/strong&gt; gitlab.com, playstation.com, bitwarden.com, nordpass.com, reddit.com (403), bestbuy.com (HTTP/2 stream error after one redirect).&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things I did not expect
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The platforms that pushed passkeys hardest do not publish it on their main domains.&lt;/strong&gt; Google, Apple and Microsoft all return 404 on the domains I tried. They ship passkeys everywhere, but this discovery file is not where they point password managers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;None of the password managers I could measure publish it for their own accounts.&lt;/strong&gt; 1Password, Dashlane and Proton return 404. The tools that read this file do not serve it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four of the eleven are not served as &lt;code&gt;application/json&lt;/code&gt;.&lt;/strong&gt; PayPal and Coinbase send &lt;code&gt;text/plain&lt;/code&gt;, TikTok sends &lt;code&gt;application/octet-stream&lt;/code&gt;, Adobe sends no Content-Type at all. The JSON parses fine in all four, so a client that sniffs the body works; a strict one that checks the header first would skip them. PayPal also points &lt;code&gt;enroll&lt;/code&gt; and &lt;code&gt;manage&lt;/code&gt; to the same URL, and Amazon and TikTok only declare &lt;code&gt;enroll&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Shopify is the only one that tags its URLs with UTM parameters (&lt;code&gt;utm_source=credential_manager&lt;/code&gt;, &lt;code&gt;utm_medium=passkey_endpoint&lt;/code&gt;). That is a nice idea: it means they can measure how many enrollments the file actually produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you run a site with passkeys
&lt;/h2&gt;

&lt;p&gt;The whole thing is a static JSON file:&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;"enroll"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/account/passkeys/new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"manage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/account/passkeys"&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;Serve it with &lt;code&gt;Content-Type: application/json&lt;/code&gt;, make sure the URLs work for a logged-out user (they should go through login and land on the right page), and add UTM tags like Shopify if you want to know whether it does anything.&lt;/p&gt;

&lt;p&gt;For background on how passkeys work and why the private key never leaves your device, I keep a plain-language explainer here: &lt;a href="https://www.pwdfortress.com/en/blog/what-is-a-passkey" rel="noopener noreferrer"&gt;what a passkey is and how it works&lt;/a&gt;. It is the sibling of an earlier test on the other discovery file, &lt;a href="https://www.pwdfortress.com/en/blog/well-known-change-password" rel="noopener noreferrer"&gt;&lt;code&gt;/.well-known/change-password&lt;/code&gt;&lt;/a&gt;, where 11 of 40 sites also got it right. Same number, by coincidence.&lt;/p&gt;

&lt;p&gt;The raw responses are easy to reproduce with the loop above; results will differ from a residential IP for the six blocked domains.&lt;/p&gt;

</description>
      <category>passkeys</category>
      <category>security</category>
      <category>webdev</category>
      <category>webauthn</category>
    </item>
    <item>
      <title>One Bing query was 62% of my impressions and never got a click</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:04:22 +0000</pubDate>
      <link>https://dev.to/ricco020/one-bing-query-was-62-of-my-impressions-and-never-got-a-click-1oo</link>
      <guid>https://dev.to/ricco020/one-bing-query-was-62-of-my-impressions-and-never-got-a-click-1oo</guid>
      <description>&lt;p&gt;I run a small site about self-hosted WireGuard, and I compute my own CTR curve from the Bing Webmaster API instead of borrowing an industry one. Today the curve said something absurd: queries phrased as questions, at positions 6 to 8, convert at &lt;strong&gt;0.037%&lt;/strong&gt;. Non-question queries in the same band convert at &lt;strong&gt;6.3%&lt;/strong&gt;. That is a factor of 170.&lt;/p&gt;

&lt;p&gt;It is not a behaviour of question searchers. It is one query.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;GetQueryStats&lt;/code&gt; for the whole property, 12 June to 30 October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;85,371 impressions, 1,467 clicks, so a site CTR of &lt;strong&gt;1.72%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;one query, &lt;code&gt;what is a vpn split tunneling&lt;/code&gt;, accounts for &lt;strong&gt;52,680 impressions and 0 clicks&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;that is &lt;strong&gt;61.7%&lt;/strong&gt; of every impression the site received in Bing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remove that single row and the site CTR becomes &lt;strong&gt;4.49%&lt;/strong&gt;. Same pages, same titles, same month.&lt;/p&gt;

&lt;p&gt;The weekly rows show its shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;week starting&lt;/th&gt;
&lt;th&gt;impressions&lt;/th&gt;
&lt;th&gt;clicks&lt;/th&gt;
&lt;th&gt;avg position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-14&lt;/td&gt;
&lt;td&gt;183&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-21&lt;/td&gt;
&lt;td&gt;2,481&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-08-28&lt;/td&gt;
&lt;td&gt;6,327&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-04&lt;/td&gt;
&lt;td&gt;10,333&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-11&lt;/td&gt;
&lt;td&gt;19,517&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-18&lt;/td&gt;
&lt;td&gt;7,562&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2026-09-25&lt;/td&gt;
&lt;td&gt;6,277&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Then nothing. Seven weeks, a climb to almost 20,000 impressions a week, never one click, then gone.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;GetQueryPageStats&lt;/code&gt; says Bing showed one page for it, &lt;a href="https://www.vpnsmith.com/en/blog/what-is-split-tunneling" rel="noopener noreferrer"&gt;our split tunneling explainer&lt;/a&gt;, for 49,356 of those impressions. If you look at that page's report you see a page with a catastrophic CTR at position 7, and the obvious move is to rewrite its title.&lt;/p&gt;

&lt;p&gt;Its title has been rewritten three times since June. None of that could move this row, because whatever produced those impressions was not a person choosing between ten blue links. I do not know what it was (a rank tracker, an answer box that satisfied the question, an automated surface), and I am not going to pretend I do. What I can say is that 52,680 impressions with zero clicks at position 6 is not a distribution humans produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I took from it
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never build a CTR curve without looking at the top contributor of each cell.&lt;/strong&gt; One row can own a band. Ours owned 97% of the "question, position 6-8" cell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report site CTR with and without the largest query.&lt;/strong&gt; If the two numbers differ by more than 2x, the average describes one query, not your site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A title rewrite is only a fix when the impressions are human.&lt;/strong&gt; Check the weekly shape first: a smooth climb to five figures with zero clicks, then a cliff, is a signature, not an audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bing rows are per query per date bucket, not per query.&lt;/strong&gt; Summing them without grouping by query is how you miss that a "cell" is a single query repeated seven times.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The page may or may not need work; this row cannot tell me either way. The thing that was broken was my instrument.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>bing</category>
      <category>webdev</category>
      <category>analytics</category>
    </item>
    <item>
      <title>A pruning rule put my renamed page in noindex: it measured the new URL, the history was on the old one</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Fri, 30 Oct 2026 20:22:12 +0000</pubDate>
      <link>https://dev.to/ricco020/a-pruning-rule-put-my-renamed-page-in-noindex-it-measured-the-new-url-the-history-was-on-the-old-371h</link>
      <guid>https://dev.to/ricco020/a-pruning-rule-put-my-renamed-page-in-noindex-it-measured-the-new-url-the-history-was-on-the-old-371h</guid>
      <description>&lt;p&gt;Two weeks ago I renamed 42 article URLs on a small VPN review site. An affiliate network's brand rules forbid using the partner's trademark in a URL, and several of our slugs did exactly that. So &lt;code&gt;/en/blog/best-nordvpn-alternatives-2026&lt;/code&gt; became &lt;code&gt;/en/blog/best-vpn-alternatives-2026&lt;/code&gt;, with a permanent redirect from the old path, and every internal link was rewritten to the new one. Textbook migration.&lt;/p&gt;

&lt;p&gt;Six days later, a separate cleanup job put the new URL in &lt;code&gt;noindex&lt;/code&gt;. Nobody noticed for another six days. Here is how two correct jobs combined into one wrong result, and the check that would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pruning rule was reasonable
&lt;/h2&gt;

&lt;p&gt;The site had too many thin pages. Google was crawling hundreds of URLs that never earned an impression. So the cleanup rule was simple: a page published before a cutoff date, with fewer than 12 impressions and zero clicks over 120 days in Search Console, goes to &lt;code&gt;noindex, follow&lt;/code&gt;, leaves the sitemap, and is listed in a reversible JSON file.&lt;/p&gt;

&lt;p&gt;293 URLs matched. Together they held 0.3 % of the site's impressions and 47 % of its sitemap. On paper, a clean cut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule read the wrong key
&lt;/h2&gt;

&lt;p&gt;Search Console reports performance &lt;strong&gt;by URL&lt;/strong&gt;. The demand for "nordvpn alternatives" had been built over months on the old path. After the rename, Google kept showing the old URL in results, because it had not recrawled it yet and had never seen the redirect.&lt;/p&gt;

&lt;p&gt;So when the pruning job asked "how many impressions did &lt;code&gt;/en/blog/best-vpn-alternatives-2026&lt;/code&gt; get in 120 days?", the honest answer was: almost none. The page was six days old in Google's eyes. The history lived on another key.&lt;/p&gt;

&lt;p&gt;The page it removed had &lt;strong&gt;7,836 impressions over 130 days at an average position of 10.8&lt;/strong&gt;, all recorded on the old URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the damage looked like
&lt;/h2&gt;

&lt;p&gt;From Search Console, for the query "nordvpn alternatives":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;th&gt;Avg. position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Last 28 days&lt;/td&gt;
&lt;td&gt;1,054&lt;/td&gt;
&lt;td&gt;10.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Last 14 days&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;35.2&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the URL Inspection API, on 30 October:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Old URL&lt;/strong&gt;: "Submitted and indexed", last crawl 1 October. Google still had not seen the redirect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New URL&lt;/strong&gt;: "Discovered, currently not indexed", never crawled. It had spent its first days in &lt;code&gt;noindex&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The redirect was correct, the canonical was correct, the page was in the sitemap and linked from 11 articles. None of that mattered while the page told crawlers not to index it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it cannot undo
&lt;/h2&gt;

&lt;p&gt;The 7 renamed URLs caught by the rule were taken out of the noindex list, the sitemap was resubmitted, and the URLs were pushed through IndexNow and the Bing Webmaster URL submission API. That restores eligibility. It does not restore the position: the new URL now has to be crawled, then earn back what the old one had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check I should have had
&lt;/h2&gt;

&lt;p&gt;Any rule that judges a page by its own history needs to know that page's previous names. In practice:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the redirect map as data&lt;/strong&gt;, not only as server config. We had 53 redirects in a config file the pruning job never read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sum metrics over the URL and every path that redirects to it&lt;/strong&gt; before applying a threshold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exclude any URL younger than one full measurement window&lt;/strong&gt; from automatic pruning. A 120-day rule should not judge a 6-day-old URL at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third point is the cheapest and would have been enough on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general pattern
&lt;/h2&gt;

&lt;p&gt;Two jobs, each correct against its own spec: the migration preserved users and links, the pruning removed dead weight. The failure was in the join: one job created a new identity for an asset, the other measured the asset by identity. If your tooling has a "rename" step and a "measure" step, check that the measure step knows about renames.&lt;/p&gt;

&lt;p&gt;If you want to see the page this happened to, it is the &lt;a href="https://www.anonymflow.com/en/blog/best-vpn-alternatives-2026" rel="noopener noreferrer"&gt;VPN alternatives comparison&lt;/a&gt; on the site. I will post the recovery numbers once Google has recrawled it.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>devops</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>Leaving Vercel with a static site: the redirects you lose without noticing</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Wed, 07 Oct 2026 11:48:32 +0000</pubDate>
      <link>https://dev.to/ricco020/leaving-vercel-with-a-static-site-the-redirects-you-lose-without-noticing-2i4j</link>
      <guid>https://dev.to/ricco020/leaving-vercel-with-a-static-site-the-redirects-you-lose-without-noticing-2i4j</guid>
      <description>&lt;p&gt;This week I moved four static Astro sites off Vercel and onto a plain container host. The builds were the easy part: &lt;code&gt;npm run build&lt;/code&gt;, copy &lt;code&gt;dist/&lt;/code&gt; into a web server image, done. What almost went wrong is everything Vercel had been doing around the build without my thinking about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that does not show up
&lt;/h2&gt;

&lt;p&gt;A static site on Vercel is not only a folder of HTML. &lt;code&gt;vercel.json&lt;/code&gt; holds redirects, headers, &lt;code&gt;cleanUrls&lt;/code&gt; and &lt;code&gt;trailingSlash&lt;/code&gt;, and the platform applies them at the edge. Copy the same &lt;code&gt;dist/&lt;/code&gt; behind a default file server and:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the home page answers 200,&lt;/li&gt;
&lt;li&gt;the build is green,&lt;/li&gt;
&lt;li&gt;the deployment is marked healthy,&lt;/li&gt;
&lt;li&gt;and every inherited 301, every &lt;code&gt;/go/...&lt;/code&gt; short link and every extensionless URL returns 404.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing reports it. No build step knows those rules existed. You find out weeks later in Search Console, or you never do. Between them the four sites carry 64 redirects, most inherited from older URL structures, plus header rules. Retyping them by hand is how one gets dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What had to be rebuilt in the web server
&lt;/h2&gt;

&lt;p&gt;I went with Caddy, and ended up translating five things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Redirects, in file order.&lt;/strong&gt; Vercel evaluates them top to bottom, so the Caddyfile wraps them in a &lt;code&gt;route&lt;/code&gt; block to keep that order. Patterns like &lt;code&gt;/old/:slug&lt;/code&gt; and &lt;code&gt;/docs/:path*&lt;/code&gt; become &lt;code&gt;path_regexp&lt;/code&gt; matchers, and &lt;code&gt;:slug&lt;/code&gt; or &lt;code&gt;$1&lt;/code&gt; in the destination become capture group placeholders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Host conditions.&lt;/strong&gt; &lt;code&gt;has: [{ "type": "host", "value": "www.example.com" }]&lt;/code&gt; becomes a &lt;code&gt;host&lt;/code&gt; matcher on the same rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Headers.&lt;/strong&gt; The catch-all &lt;code&gt;/(.*)&lt;/code&gt; block becomes one global &lt;code&gt;header&lt;/code&gt; directive, path-specific ones get a named matcher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;cleanUrls&lt;/code&gt; and &lt;code&gt;trailingSlash: false&lt;/code&gt;.&lt;/strong&gt; A redirect from &lt;code&gt;/page.html&lt;/code&gt; to &lt;code&gt;/page&lt;/code&gt;, a redirect away from the trailing slash, then &lt;code&gt;try_files {path} {path}/index.html {path}.html&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Language negotiation on &lt;code&gt;/&lt;/code&gt;.&lt;/strong&gt; One site had a small &lt;code&gt;middleware.ts&lt;/code&gt; sending visitors to &lt;code&gt;/fr&lt;/code&gt; or &lt;code&gt;/es&lt;/code&gt; from &lt;code&gt;Accept-Language&lt;/code&gt;. In Caddy that is a &lt;code&gt;header_regexp&lt;/code&gt; matcher and a 302, with &lt;code&gt;Vary: Accept-Language&lt;/code&gt; and &lt;code&gt;Cache-Control: no-store&lt;/code&gt; so no cache pins one visitor's language for everyone. Crawlers send no &lt;code&gt;Accept-Language&lt;/code&gt;, so they keep the default root.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One detail I would not have guessed: short links. A rule on &lt;code&gt;/go/docs&lt;/code&gt; does not match &lt;code&gt;/go/docs/&lt;/code&gt;, and links copied around the web come in both forms. The generated matcher lists both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refuse, do not skip
&lt;/h2&gt;

&lt;p&gt;The part I care most about is what the script does with a rule it cannot translate. &lt;code&gt;rewrites&lt;/code&gt;, &lt;code&gt;routes&lt;/code&gt;, &lt;code&gt;missing&lt;/code&gt;, and &lt;code&gt;has&lt;/code&gt; conditions other than &lt;code&gt;host&lt;/code&gt; stop it with an error naming the rule. A warning would scroll past, and a skipped redirect is exactly the silent 404 the whole exercise is meant to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things that bit me anyway
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm ci&lt;/code&gt; failed in the container&lt;/strong&gt; on one site because the lockfile had drifted from &lt;code&gt;package.json&lt;/code&gt;. Vercel had been tolerating it. Decide whether you want to fix the lockfile or use &lt;code&gt;npm install&lt;/code&gt; in the build stage, but find out before the cutover.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify on the served site, not the config.&lt;/strong&gt; After each deploy I requested a sample of old URLs against the container's public address and checked the status code and the &lt;code&gt;Location&lt;/code&gt; header. A Caddyfile that parses says nothing about whether your patterns mean what you think.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The script
&lt;/h2&gt;

&lt;p&gt;It is one Python file, standard library only: it reads &lt;code&gt;vercel.json&lt;/code&gt; and writes a &lt;code&gt;Caddyfile&lt;/code&gt;, a two-stage &lt;code&gt;Dockerfile&lt;/code&gt; (Node build, then &lt;code&gt;caddy:2-alpine&lt;/code&gt;) and a &lt;code&gt;.dockerignore&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 vercel-to-caddy.py &amp;lt;repo&amp;gt; &lt;span class="nt"&gt;--lang-root&lt;/span&gt; fr,es &lt;span class="nt"&gt;--out&lt;/span&gt; dist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source, a worked example and the list of what is and is not handled: &lt;a href="https://gitlab.com/ler.eric/vercel-to-caddy" rel="noopener noreferrer"&gt;gitlab.com/ler.eric/vercel-to-caddy&lt;/a&gt;. It only covers static output; anything that needs functions or server rendering is out of scope.&lt;/p&gt;

&lt;p&gt;I do this kind of migration and site work for clients at &lt;a href="https://z-ax.com" rel="noopener noreferrer"&gt;Z-AX&lt;/a&gt;. If you have a &lt;code&gt;vercel.json&lt;/code&gt; pattern the script refuses, open an issue with the rule: the refusal list is where it should grow next.&lt;/p&gt;

</description>
      <category>caddy</category>
      <category>vercel</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>80,000 Search Console impressions, zero clicks: three checks before you call it an opportunity</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Wed, 07 Oct 2026 08:20:18 +0000</pubDate>
      <link>https://dev.to/ricco020/80000-search-console-impressions-zero-clicks-three-checks-before-you-call-it-an-opportunity-3gdl</link>
      <guid>https://dev.to/ricco020/80000-search-console-impressions-zero-clicks-three-checks-before-you-call-it-an-opportunity-3gdl</guid>
      <description>&lt;p&gt;One of my sites went from about 850 Search Console impressions a day to 16,000 in a week. Before rewriting anything to "capture the opportunity", I spent ten minutes on the numbers. I am glad I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Over 28 days the site showed 133,505 impressions. One page carried 88,640 of them: an article about &lt;a href="https://www.anonymflow.com/en/blog/real-price-of-vpns-with-commitment" rel="noopener noreferrer"&gt;what NordVPN really costs over 24 months&lt;/a&gt;. One query carried most of that page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;th&gt;Avg. position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nordvpn cost&lt;/td&gt;
&lt;td&gt;80,721&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;9.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nord vpn preis&lt;/td&gt;
&lt;td&gt;6,729&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;2.6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero clicks at position 9 on eighty thousand impressions is unusual. Zero clicks at position 2.6 on nearly seven thousand is not something a human audience produces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three checks that took the "opportunity" apart
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Compare with the site's own pages at the same rank.&lt;/strong&gt; On the same property, in the same period, a French article at position 7.3 got 18 clicks from 985 impressions, and a Spanish one at 6.7 got 9 from 986. So the site does get clicks at these positions. This one query does not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Look at who is supposedly searching.&lt;/strong&gt; The property's top countries by impressions were Austria (18,895, 0 clicks), Denmark, the United States, Norway and the Netherlands. For an English page about a price in euros, Austria and Norway leading with no clicks at all is not a reader profile. 83% of impressions were on desktop, while clicks were split almost evenly between desktop and mobile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Look at the shape over time.&lt;/strong&gt; Daily impressions sat around 800, jumped to 4,953 on 24 September, stayed between 9,000 and 16,700 for six days, then dropped to 2,579 on 1 October. Demand for a product price does not behave like that. A scheduled job does.&lt;/p&gt;

&lt;p&gt;I cannot prove what generated those impressions. Rank trackers and scrapers querying Google at scale are the obvious candidates, and Search Console counts an impression whenever the result is rendered in a results page, whoever asked.&lt;/p&gt;

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

&lt;p&gt;Nothing on the page. Its title already answers the query, and rewriting a title to chase readers who are not there risks losing the ones who are.&lt;/p&gt;

&lt;p&gt;What I changed is the rule I work by: &lt;strong&gt;an impressions number is not a demand number until the clicks at that position look like the rest of the site.&lt;/strong&gt; The check is cheap:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the page's query with the most impressions.&lt;/li&gt;
&lt;li&gt;Find two other pages on the same property at a similar average position.&lt;/li&gt;
&lt;li&gt;Compare click-through rates. An order of magnitude apart, with zero clicks, means the impressions are not people.&lt;/li&gt;
&lt;li&gt;Split by country and device. If the split looks nothing like the pages that do get clicks, stop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dashboard total had told me this site was sitting on its biggest opening of the year. Excluding that single query, its impressions for the period were about 52,800, with the same 180 clicks. That is the number I plan with now.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Three of the eight sites that support /.well-known/change-password do not support it</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sun, 23 Aug 2026 22:31:06 +0000</pubDate>
      <link>https://dev.to/ricco020/three-of-the-eight-sites-that-support-well-knownchange-password-do-not-support-it-251p</link>
      <guid>https://dev.to/ricco020/three-of-the-eight-sites-that-support-well-knownchange-password-do-not-support-it-251p</guid>
      <description>&lt;p&gt;There is a small W3C standard called &lt;a href="https://w3c.github.io/webappsec-change-password-url/" rel="noopener noreferrer"&gt;A Well-Known URL for Changing Passwords&lt;/a&gt;. A site serves &lt;code&gt;/.well-known/change-password&lt;/code&gt;, redirects it to its real change-password page, and password managers can send a user straight there instead of making them hunt through account settings.&lt;/p&gt;

&lt;p&gt;I checked 24 well known sites for it tonight. The interesting part is not how many support it. It is that &lt;strong&gt;three of the eight apparent supporters do not support it at all&lt;/strong&gt;, and the specification predicted exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap is written into the spec
&lt;/h2&gt;

&lt;p&gt;A naive check is "does &lt;code&gt;/.well-known/change-password&lt;/code&gt; return 200". That check is wrong, because plenty of servers return 200 for &lt;em&gt;everything&lt;/em&gt;. A SPA with a catch-all route will happily serve its shell for any path you invent.&lt;/p&gt;

&lt;p&gt;So the spec includes a countermeasure. Section 5 tells clients to also request a URL that should never exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/.well-known/resource-that-should-not-exist-whatever-forty-two
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns 200 too, the server is not answering your question. It is just saying 200 at everything. The support signal is void.&lt;/p&gt;

&lt;p&gt;That is a rare thing to find in a standard: the authors anticipated the false positive and shipped the control with the feature.&lt;/p&gt;

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

&lt;p&gt;Two requests per host, one for the standard path and one for the control path, real HTTP, browser User-Agent, 24 hosts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supports it for real   5 / 24     change-password 200, control 404
false positive         3 / 24     change-password 200, control 200
no support            16 / 24     404 (13), 403 (2), 400 (1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real support:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;github.com        200 / 404
reddit.com        200 / 404
wordpress.com     200 / 404
apple.com         200 / 404
1password.com     200 / 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;False positives, which a 200-only check would have counted as wins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;twitter.com       200 / 200
netflix.com       200 / 200
linkedin.com      200 / 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without the control request I would have reported 8 supporters instead of 5. That is a 60 % overcount, from a check that never threw an error and looked green all the way down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;I included six password managers, since this standard exists mainly for their benefit. They are the consumers of the signal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1password.com     supports it
bitwarden.com     404
dashlane.com      404
proton.me         404
lastpass.com      404
nordpass.com      404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One out of six serves the standard that password managers rely on. I am not going to pretend that is a scandal, these are marketing domains and the account systems often live elsewhere. But if you are building anything that touches &lt;a href="https://www.pwdfortress.com/en/blog/are-passkeys-safe" rel="noopener noreferrer"&gt;password and passkey hygiene&lt;/a&gt;, it is a useful reminder that ecosystem support is thinner than the existence of a spec suggests.&lt;/p&gt;

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

&lt;p&gt;It is genuinely a two line change on most stacks. Redirect &lt;code&gt;/.well-known/change-password&lt;/code&gt; to your existing change-password page with a 302, and make sure your 404 handler actually returns 404 for unknown paths under &lt;code&gt;/.well-known/&lt;/code&gt;. That second half is the part people get wrong, and it is the half that makes the first half detectable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general lesson
&lt;/h2&gt;

&lt;p&gt;I have spent a lot of this week finding checks that returned a confident answer to a question slightly different from the one I meant to ask. This is the same shape, with one difference: here the spec authors saw it coming and gave you the control request for free.&lt;/p&gt;

&lt;p&gt;Most of the time nobody hands you the control. You have to think of it yourself, which usually means asking "what would this check return if the thing I am testing were completely absent?" If the answer is "the same thing", the check is decorative.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>standards</category>
      <category>testing</category>
    </item>
    <item>
      <title>Google said it had never heard of my URL. The URL was fine, my probe was not.</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Sun, 23 Aug 2026 22:23:06 +0000</pubDate>
      <link>https://dev.to/ricco020/google-said-it-had-never-heard-of-my-url-the-url-was-fine-my-probe-was-not-53gg</link>
      <guid>https://dev.to/ricco020/google-said-it-had-never-heard-of-my-url-the-url-was-fine-my-probe-was-not-53gg</guid>
      <description>&lt;p&gt;Last night I ran Google's URL Inspection API against a site I run and got this back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;coverageState &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Google&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ne&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reconnait&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pas&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cette&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;URL'&lt;/span&gt;
&lt;span class="na"&gt;lastCrawl     &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;JAMAIS&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Google has never heard of this URL." On a page that had been in the sitemap for weeks.&lt;/p&gt;

&lt;p&gt;I was about to open a very large investigation. The page was fine. My probe was not. Here are the three ways that API lied to me in one sitting, and the one number that finally told me something true.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: www and non-www are different URLs to this API
&lt;/h2&gt;

&lt;p&gt;I passed &lt;code&gt;https://save-my-disk.com/fr/blog/carte-sd-non-detectee&lt;/code&gt;. The sitemap declares &lt;code&gt;https://www.save-my-disk.com/...&lt;/code&gt;. Same site to a human. Two different URLs to the API.&lt;/p&gt;

&lt;p&gt;The result is not an error. It is not a 404. It is a confident, well-formed answer to a question I did not mean to ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;without www &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Google&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;ne&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reconnait&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;pas&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cette&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;URL'&lt;/span&gt;   &lt;span class="s"&gt;lastCrawl JAMAIS&lt;/span&gt;
&lt;span class="na"&gt;with www    &lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;  &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Envoyee&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;et&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;indexee'&lt;/span&gt;                  &lt;span class="s"&gt;lastCrawl 2026-07-18&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same page. Indexed the whole time.&lt;/p&gt;

&lt;p&gt;The rule I now follow: &lt;strong&gt;never compose the URL you inspect. Read it out of the sitemap you actually serve.&lt;/strong&gt; I fetch &lt;code&gt;sitemap.xml&lt;/code&gt;, grep the slug, and inspect that exact string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: the &lt;code&gt;indexed&lt;/code&gt; field on sitemaps is zero for everyone
&lt;/h2&gt;

&lt;p&gt;The Sitemaps endpoint returns a &lt;code&gt;contents&lt;/code&gt; block that looks like it answers the only question you care about:&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="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"submitted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3258&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"indexed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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;That &lt;code&gt;indexed: 0&lt;/code&gt; is not a measurement. I have a site doing 1677 impressions a month reporting &lt;code&gt;indexed: 0&lt;/code&gt;. Every property I own reports &lt;code&gt;indexed: 0&lt;/code&gt;. If you build a dashboard on that field you will build an alarm that fires forever.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;errors&lt;/code&gt; and &lt;code&gt;warnings&lt;/code&gt; from the same block. Those are real. Ignore &lt;code&gt;indexed&lt;/code&gt; and inspect URLs individually if you want indexing state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 3: two states that read alike and mean opposite things
&lt;/h2&gt;

&lt;p&gt;This is the one that matters, and it is the reason the whole exercise was worth it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Discovered - currently not indexed"&lt;/strong&gt; means Googlebot knows the URL exists and &lt;strong&gt;has never fetched it&lt;/strong&gt;. &lt;code&gt;lastCrawl&lt;/code&gt; is empty.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Crawled - currently not indexed"&lt;/strong&gt; means Googlebot &lt;strong&gt;fetched the page, read it, and declined to index it&lt;/strong&gt;. &lt;code&gt;lastCrawl&lt;/code&gt; has a date.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They sit next to each other in the same report. They call for opposite work. The first is a crawl budget and discovery problem: more internal links, sitemap pings, authority. The second is a quality or duplication judgment: the page was seen and found wanting, and adding more pages like it makes things worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;I sampled 14 article URLs at random from a 590-page corpus on &lt;a href="https://www.save-my-disk.com/en/blog/sd-card-not-showing-up" rel="noopener noreferrer"&gt;a data recovery site I run&lt;/a&gt;, and inspected each one. I validated the probe on a known-healthy control page first, which came back "Submitted and indexed" as expected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 4/14   Submitted and indexed                      29 %
 4/14   Google does not recognise this URL     |
 4/14   Discovered - currently not indexed     |   8/14 with lastCrawl = never
 2/14   Crawled - currently not indexed            fetched, then declined
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Eight of fourteen pages had never been fetched at all. Only two had been read and rejected.&lt;/p&gt;

&lt;p&gt;I had spent the previous hour preparing to write another article for that site. The measurement says that would have produced a 657th URL that Googlebot has never visited. The bottleneck is not the writing. It is that two thirds of what already exists has never been looked at.&lt;/p&gt;

&lt;p&gt;That reframes the work entirely: internal linking, sitemap hygiene and off-site authority, instead of publishing volume. Not a conclusion I wanted, which is usually a sign it is the real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Read the URL out of the artifact you serve. Do not build it from a folder name or a slug you remember.&lt;/li&gt;
&lt;li&gt;Run any new probe against a page you already know is healthy, &lt;strong&gt;before&lt;/strong&gt; you trust it on the page you are worried about.&lt;/li&gt;
&lt;li&gt;When a check returns zero on everything it looks at, suspect the check first.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The API is not wrong in any of these cases. It answers precisely the question you asked. The gap between that question and the one you meant is where the hour goes.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>googlecloud</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Eight checks lied to me in one day, and none of them threw an error</title>
      <dc:creator>ricco020</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:11:19 +0000</pubDate>
      <link>https://dev.to/ricco020/eight-checks-lied-to-me-in-one-day-and-none-of-them-threw-an-error-18m1</link>
      <guid>https://dev.to/ricco020/eight-checks-lied-to-me-in-one-day-and-none-of-them-threw-an-error-18m1</guid>
      <description>&lt;p&gt;In one working day I wrote or relied on eight checks that were wrong. &lt;strong&gt;Not one of them threw an&lt;br&gt;
error.&lt;/strong&gt; Every single one returned a plausible answer, which is exactly why each cost me time.&lt;/p&gt;

&lt;p&gt;Here they are, with what they actually returned.&lt;/p&gt;
&lt;h2&gt;
  
  
  The four ways a check can lie
&lt;/h2&gt;
&lt;h3&gt;
  
  
  1. It never runs
&lt;/h3&gt;

&lt;p&gt;I wrote assertions that validate prices against the strings shown to users, then deliberately&lt;br&gt;
corrupted one to confirm the build would fail.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sabotaged price string
BUILD EXIT=0     &amp;lt;- the guard saw nothing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The assertions run on import. &lt;strong&gt;Nothing imported the module yet.&lt;/strong&gt; After wiring it into a page that&lt;br&gt;
is actually built, the same sabotage gave &lt;code&gt;EXIT=1&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A check nothing calls protects nothing, and it is &lt;strong&gt;worse&lt;/strong&gt; than no check, because you believe you&lt;br&gt;
are covered.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  2. It caches a stale verdict
&lt;/h3&gt;

&lt;p&gt;A crawler wrote &lt;code&gt;sitemap_absent: true&lt;/code&gt; on the first pass. I fixed the fetch, the data came back&lt;br&gt;
correctly, and the report still printed &lt;strong&gt;"SITEMAP NOT SERVED"&lt;/strong&gt; — because the flag was never&lt;br&gt;
cleared.&lt;/p&gt;

&lt;p&gt;The JSON on disk said 445 URLs. The screen said absent. &lt;strong&gt;I only caught it by opening the artefact&lt;br&gt;
instead of reading the summary.&lt;/strong&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  3. It asks for the wrong shape
&lt;/h3&gt;

&lt;p&gt;Counting rendered questions on a page returned &lt;strong&gt;0&lt;/strong&gt;. I nearly reported that a paid feature shipped&lt;br&gt;
nothing.&lt;/p&gt;

&lt;p&gt;My selectors looked for DOM classes. The items were &lt;strong&gt;props of a client component&lt;/strong&gt;, serialised into&lt;br&gt;
the framework payload as &lt;code&gt;{"q": …}&lt;/code&gt;. The page was 16,908 bytes in both measurements. &lt;strong&gt;The content&lt;br&gt;
was always there; only my way of counting was wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fix was not a better guess. It was reading the type definition and counting what the code&lt;br&gt;
actually emits.&lt;/p&gt;
&lt;h3&gt;
  
  
  4. It includes itself in its own sample
&lt;/h3&gt;

&lt;p&gt;Two on the same day, and this is my favourite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pgrep &lt;span class="nt"&gt;-af&lt;/span&gt; &lt;span class="s2"&gt;"next start"&lt;/span&gt;
&lt;span class="c"&gt;# returns only the bash -c wrapper that contains the pattern&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old server was still holding the port under a different process name. I found it with&lt;br&gt;
&lt;code&gt;ss -ltnp&lt;/code&gt; — &lt;strong&gt;searching for who holds the port always works; searching for a name you assume it has&lt;br&gt;
does not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then, checking whether a new slug collided with existing ones, I globbed the directory — including&lt;br&gt;
the file I had just written. It saw itself and answered &lt;strong&gt;"already taken."&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A probe that includes itself in its own sample always confirms what it is looking for.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The two failure modes, and why calibration is the whole job
&lt;/h2&gt;

&lt;p&gt;A check can fail in exactly two directions, and I hit both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;too weak   counting accented characters       -&amp;gt; a text with a few accents passes while being wrong
too strong requiring a fixed list of words    -&amp;gt; cries on correct text that had no reason to use them
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The German page was correct. My check demanded words the article never needed, and shouted anyway.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A false alarm that repeats gets ignored, and an ignored check protects nothing.&lt;/strong&gt; That is the&lt;br&gt;
quietest way to lose a safeguard: it stays in the code, it goes green in the reader's head, and it&lt;br&gt;
stops saying anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The calibrated version turned out to be: &lt;strong&gt;a non-zero count of language-specific characters AND a&lt;br&gt;
few witness words present — not all of them.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now, and it costs three minutes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test the guard by making it fail on purpose.&lt;/strong&gt; Not once written, but every time it is written.&lt;br&gt;
Sabotage the input, confirm it screams, restore. Two of my checks passed that test only on the second&lt;br&gt;
attempt, and I would never have known otherwise.&lt;/p&gt;

&lt;p&gt;Two other habits that came out of the same day:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read the artefact, not the summary.&lt;/strong&gt; The JSON, the manifest, the served HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove identity, not just presence.&lt;/strong&gt; "The server responds" is not "my version responds." A
deployment I thought I was testing had never started; the old process answered &lt;code&gt;200&lt;/code&gt; all along.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is the same shape as a thing I ended up writing about elsewhere: a VPN kill switch&lt;br&gt;
toggle being on tells you what the app intends, not what the operating system will do when the tunnel&lt;br&gt;
dies at an awkward moment. I wrote up the four tests that actually settle it&lt;br&gt;
&lt;a href="https://www.vpnsmith.com/en/blog/vpn-kill-switch-test-2026" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eight wrong checks, zero errors raised.&lt;/strong&gt; A check that crashes gets fixed. A check that answers&lt;br&gt;
slightly the wrong question gets believed.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>debugging</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
