<?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: Ibrahim Hajjaj</title>
    <description>The latest articles on DEV Community by Ibrahim Hajjaj (@ibrahimwithi).</description>
    <link>https://dev.to/ibrahimwithi</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%2F4110222%2F4f26e8a9-a63a-4a5c-ac9d-3c08a7622d9b.jpg</url>
      <title>DEV Community: Ibrahim Hajjaj</title>
      <link>https://dev.to/ibrahimwithi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ibrahimwithi"/>
    <language>en</language>
    <item>
      <title>Your Search Console script is undercounting and it will never tell you</title>
      <dc:creator>Ibrahim Hajjaj</dc:creator>
      <pubDate>Thu, 24 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ibrahimwithi/your-search-console-script-is-undercounting-and-it-will-never-tell-you-4clj</link>
      <guid>https://dev.to/ibrahimwithi/your-search-console-script-is-undercounting-and-it-will-never-tell-you-4clj</guid>
      <description>&lt;p&gt;I have shipped both of these bugs. So, I would guess, has anyone who has written more than about fifty lines against the Google Search Console API. Neither throws, neither logs, and both produce numbers confident enough to put in a report.&lt;/p&gt;

&lt;p&gt;They are the same mistake wearing two costumes: &lt;strong&gt;treating a row that is not there as a row whose value is zero.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug one: summing a dimension Google censors
&lt;/h2&gt;

&lt;p&gt;You want clicks for last month. The obvious call gives you a breakdown by query, so you add up the clicks column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;searchconsole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;startDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;query&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clicks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&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;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clicks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// wrong&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search Console does not return every query. It withholds queries issued by very few users, as a privacy measure, and it does not tell you how many it held back or how much traffic they carried. The rows you get are real. The set is incomplete, and incomplete by a different amount every window.&lt;/p&gt;

&lt;p&gt;So that sum is not "clicks last month". It is "clicks from the queries Google felt comfortable showing me last month", which is a smaller number that moves for reasons having nothing to do with your site.&lt;/p&gt;

&lt;p&gt;Now do the thing everyone does next and compare two months. You have manufactured a trend out of Google's disclosure policy. A month where more of your traffic came from long-tail queries looks like a month where you lost traffic.&lt;/p&gt;

&lt;p&gt;The fix is one line and completely unobvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Clicks and impressions come from the date dimension. Summing the query&lt;/span&gt;
&lt;span class="c1"&gt;// dimension undercounts, because Search Console withholds low-volume queries,&lt;/span&gt;
&lt;span class="c1"&gt;// and that gap reads later as a real decline rather than as withheld rows.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;byDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;searchAnalytics&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Totals come from the &lt;strong&gt;date&lt;/strong&gt; dimension. Always. The query and page dimensions are for "what are my top queries", never for "how much did I get". If you want both, that is two calls, and they will not reconcile, and the difference between them is the censored tail rather than a bug in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug two: a cut-off list read as a complete one
&lt;/h2&gt;

&lt;p&gt;This one is worse, because it does not undercount. It fabricates an event.&lt;/p&gt;

&lt;p&gt;Search Console caps rows per query and returns them &lt;strong&gt;ordered by clicks, descending&lt;/strong&gt;. My insight tools asked for 5,000 rows, got 5,000 rows, and said nothing about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;insightRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dimensions&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="p"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;  &lt;span class="c1"&gt;// wrong&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sitting on a site with more than 5,000 rows, that cut lands exactly on the low-click rows. Which are exactly the rows a striking-distance report, a CTR-gap report and a cannibalisation report exist to find. The tools were reliably blind in precisely their own subject area, and nothing said so.&lt;/p&gt;

&lt;p&gt;Then the period comparison joined two of those lists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([...&lt;/span&gt;&lt;span class="nx"&gt;currentByKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;previousByKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
&lt;span class="c1"&gt;// for each key: (current?.clicks ?? 0) - (previous?.clicks ?? 0)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;?? 0&lt;/code&gt;. There it is again.&lt;/p&gt;

&lt;p&gt;A query that ranked 4,900th last month and 5,100th this month is absent from the current list. It is scored as zero clicks. The tool reports it as having lost every click it had, ranks it near the top of the losers table, and hands you a collapse that never happened. From the tool whose only job is telling you what changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix is a probe row and a third state
&lt;/h3&gt;

&lt;p&gt;Ask for one row more than you intend to analyse. That single extra row is the only thing that separates "there were exactly this many" from "the list was cut":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;INSIGHT_ROW_LIMIT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;insightRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// The one extra row is the probe.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dimensions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rowLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;INSIGHT_ROW_LIMIT&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchInsightRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestBody&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchConsole&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchanalytics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;siteUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requestBody&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;INSIGHT_ROW_LIMIT&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&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="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;truncated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fetched&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;INSIGHT_ROW_LIMIT&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 probe row is never analysed. It exists to answer one boolean.&lt;/p&gt;

&lt;p&gt;Then that boolean has to change the arithmetic, not just add a footnote:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;joinedKeys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;currentRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;currentByKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;previousRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;previousByKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// A cut-off list says nothing about the rows below the cut, so a key absent&lt;/span&gt;
  &lt;span class="c1"&gt;// from a truncated side is unknown there, not zero. Scoring it as zero turns&lt;/span&gt;
  &lt;span class="c1"&gt;// a row that merely slipped past the cut into a total gain or total loss.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;currentRow&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;currentTruncated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previousRow&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;previousTruncated&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;droppedAsUnknown&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that it is conditional on truncation. If the list came back complete, an absent key genuinely is zero and should be reported as a real loss. The correction only applies where the data actually stopped.&lt;/p&gt;

&lt;p&gt;And the count of what was withheld goes in the output, because a tool that silently drops rows to avoid lying has just found a quieter way to lie:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Note: Search Console returned more than 5000 rows for the current window,
so a key missing from a cut-off window is unknown there rather than lost;
keys left out of this comparison for that reason: 37.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The rule underneath both
&lt;/h2&gt;

&lt;p&gt;Two states are not enough. You need three.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;present&lt;/strong&gt; with a value&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;absent&lt;/strong&gt;, and you know the list was complete, so absent means zero&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;absent&lt;/strong&gt;, and the list was cut off or censored, so absent means &lt;strong&gt;unknown&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;?? 0&lt;/code&gt; collapses the second and third into each other. It is a default value standing in for a fact you do not have, and it is invisible because the type system is perfectly happy: &lt;code&gt;number | undefined&lt;/code&gt; becomes &lt;code&gt;number&lt;/code&gt; and everything downstream typechecks and runs and produces a chart.&lt;/p&gt;

&lt;p&gt;Every API that paginates, ranks, samples, or withholds for privacy has this shape. Search Console does all four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other thing Search Console will not do for you
&lt;/h2&gt;

&lt;p&gt;Its window is 16 months, rolling. Whatever last spring looked like is gone, and no API call brings it back. You cannot answer "did the change I made six weeks ago work" unless six weeks ago you thought to write the numbers down.&lt;/p&gt;

&lt;p&gt;That is most of why I built the tool this code lives in. It snapshots Search Console, App Store listings, Play installs and wordpress.org active installs to files you keep, so a future you has a before to compare against.&lt;/p&gt;

&lt;p&gt;It is &lt;a href="https://github.com/ibrahimhajjaj/seo-console-mcp" rel="noopener noreferrer"&gt;seo-console-mcp&lt;/a&gt;, MIT, an MCP server and a CLI over the same tool registry. Four of the tools run with no credentials at all if you want to try it without the service-account dance.&lt;/p&gt;

&lt;p&gt;Yes, a good chunk of it is a wrapper around an API. The part that is not a wrapper is everything above: a missing row is unknown rather than zero, a truncated list says it was truncated, a failed surface is recorded in place rather than dropped from the output, and totals never come from summing a dimension Google censors.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>api</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build a support agent on Cloudflare Workers that cites its sources</title>
      <dc:creator>Ibrahim Hajjaj</dc:creator>
      <pubDate>Wed, 23 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ibrahimwithi/build-a-support-agent-on-cloudflare-workers-that-cites-its-sources-5ol</link>
      <guid>https://dev.to/ibrahimwithi/build-a-support-agent-on-cloudflare-workers-that-cites-its-sources-5ol</guid>
      <description>&lt;p&gt;Most "AI support agent" tutorials end with a chat box that confidently makes something up. The fix is not a better prompt. It is making the agent show which page it got the answer from, so the person reading can check it.&lt;/p&gt;

&lt;p&gt;This walks through putting one on Cloudflare Workers: what the code is, and the four things that broke when I did it. The gotchas are the useful half.&lt;/p&gt;

&lt;p&gt;Everything here is from &lt;a href="https://github.com/ibrahimhajjaj/recourse" rel="noopener noreferrer"&gt;recourse&lt;/a&gt;, which is MIT and self-hosted, but the Workers-specific parts apply to anything you build on this runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handler is already a Worker
&lt;/h2&gt;

&lt;p&gt;A Worker is a function from a &lt;code&gt;Request&lt;/code&gt; to a &lt;code&gt;Response&lt;/code&gt;. If your chat handler is written against &lt;code&gt;Request&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;, &lt;code&gt;fetch&lt;/code&gt; and Web Crypto, there is nothing to adapt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createChatHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/core/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;models&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/core/models&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;knowledge&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./knowledge.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createChatHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;index&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;knowledge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;embedder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;persona&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Ada&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;business&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lumen Coffee Roasters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;I can't find that in our help pages.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index is imported rather than fetched, so it is bundled with the Worker and a cold start is a JSON parse. No warm-up, no vector database to reach across the network before you can answer the first question.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;wrangler.jsonc&lt;/code&gt; is short, and what is missing from it is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"recourse-worker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src/index.ts"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-08-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"observability"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;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;No &lt;code&gt;nodejs_compat&lt;/code&gt;. No compatibility flags at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: import subpaths, not the root
&lt;/h2&gt;

&lt;p&gt;This is the one that gets everybody, because the failure is a bundle size and a polyfill rather than an error you can read.&lt;/p&gt;

&lt;p&gt;The package root re-exports &lt;code&gt;ingest&lt;/code&gt;, which reads documents off disk, which imports &lt;code&gt;node:fs&lt;/code&gt;. Import the root on a Worker and you drag the filesystem into a runtime that has no filesystem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrong: pulls node:fs through the ingest re-export&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createChatHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// right: none of these touch the filesystem&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createChatHandler&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/core/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;models&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/core/models&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createAgent&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/core/agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth automating rather than remembering. A build step that greps the bundle for Node built-ins and fails on a hit costs twenty lines and catches this the first time somebody adds an import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// check-bundle.mjs, run in CI&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NODE_BUILTINS&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;node:&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;fs|path|crypto|stream|buffer|os&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;NODE_BUILTINS&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;bundle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a Node built-in reached the Worker bundle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`bundle is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;bundle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; bytes, budget is 200KB`&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;For reference the working bundle is 129.4 KB with no Node built-ins. When that guard fires it is telling you something Node-only leaked onto the serving path, which is a design signal and not just a size complaint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: &lt;code&gt;process&lt;/code&gt; throws, it does not return undefined
&lt;/h2&gt;

&lt;p&gt;On a Worker there is no &lt;code&gt;process&lt;/code&gt; global. Reading it is a &lt;code&gt;ReferenceError&lt;/code&gt;, not &lt;code&gt;undefined&lt;/code&gt;, so the usual defensive pattern does not save you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// throws on a Worker. It does not evaluate to the fallback.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fallback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The environment arrives as the second argument to &lt;code&gt;fetch&lt;/code&gt;, per request, and anything that reads configuration has to be handed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Env&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="nf"&gt;createChatHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="nx"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEnvironment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the handler is built inside &lt;code&gt;fetch&lt;/code&gt; rather than at module scope. At module scope there is no &lt;code&gt;env&lt;/code&gt; yet.&lt;/p&gt;

&lt;p&gt;This one has a nasty second-order version. If you leave the model unset it can fall back to a hosted gateway, and that provider reads &lt;code&gt;process.env&lt;/code&gt; internally. So you get &lt;code&gt;process is not defined&lt;/code&gt; at request time, which reads like a bundling problem, when the actual cause is a missing configuration value. Pass an explicit model and the error goes away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: &lt;code&gt;wrangler dev&lt;/code&gt; does not forward your shell
&lt;/h2&gt;

&lt;p&gt;Correct behaviour, and surprising exactly once. Your exported variables are not visible to the Worker. They go in &lt;code&gt;.dev.vars&lt;/code&gt;, which you git-ignore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;OPENAI_COMPATIBLE_BASE_URL &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://localhost:11434/v1"&lt;/span&gt;
OPENAI_COMPATIBLE_MODEL &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"qwen3:4b"&lt;/span&gt;
OPENAI_COMPATIBLE_API_KEY &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ollama"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That points at a local Ollama, so you can develop the whole thing without an API key or a bill. In production the same three variables point at whatever OpenAI-compatible endpoint you like, including Workers AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Citations, which is the actual point
&lt;/h2&gt;

&lt;p&gt;Retrieval returns passages, and each passage remembers which document it came from. Keep that association through to the answer and render it, rather than flattening the passages into one blob of context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;To hand a conversation to a person, you can use the escalate function,
which marks the conversation as belonging to a person [1].

[1] Handing a conversation to the desk you already run
    · Getting out of the way once a person arrives
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason to care is not neatness. An answer with a source is falsifiable: the reader can open the page and see you are wrong. An answer without one has to be taken on trust, and support answers taken on trust are how people end up following instructions for the previous version of your product.&lt;/p&gt;

&lt;p&gt;This also runs with &lt;strong&gt;no API key at all&lt;/strong&gt;. With no embedder configured, retrieval is BM25 keyword matching, which needs no credential and no vector store. You get worse recall than hybrid search, and you get it before signing up for anything, which is the right trade for a first run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding D1, and the limit that will catch you
&lt;/h2&gt;

&lt;p&gt;Conversations go in D1 through a binding, so there is no connection pool, no credential and nothing to exhaust:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&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;"d1_databases"&lt;/span&gt;&lt;span class="p"&gt;:&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;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DB"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"database_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"recourse"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"database_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&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;span class="p"&gt;]&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;d1Store&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@recourse-ai/store-d1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;d1Store&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The free tier's limit is &lt;strong&gt;50 queries per invocation&lt;/strong&gt;, not per day. Per invocation. A handler that runs a query per retrieved chunk can reach it inside one request while your dashboard shows almost no usage for the day, so the failure reads as an intermittent bug rather than a quota. Batch reads, or keep the index bundled and use D1 only for conversation state.&lt;/p&gt;

&lt;h2&gt;
  
  
  One check worth running before a customer does
&lt;/h2&gt;

&lt;p&gt;Every credential here is passed as an option rather than read from a global, which is the right shape and has one cost: nothing validates it until a webhook arrives and fails. A wrong signing secret looks exactly like silence.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @recourse-ai/core doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  FAIL  embedding model  the index was built with "nomic-embed-text" but the
                         environment says "mxbai-embed-large"
  ok    index            28 chunks from 6 documents, hybrid
  ok    model            "qwen3:4b" is available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That first line is the check worth having on its own. A query vector from one embedding model compared against stored vectors from another is not a comparable number. Nothing errors. You just get quietly bad answers, and you go looking at your prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;Being honest about the shape of it, because "self-hosted" is not free:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You are on call. A hosted tool has someone else on call.&lt;/li&gt;
&lt;li&gt;There is no dashboard to hand a support lead. Configuration is code in your
repository, which is the point if your refund flow does not fit a settings page, and a real cost if it does.&lt;/li&gt;
&lt;li&gt;Keyword-only retrieval has worse recall than hybrid. Configure an embedder
when you have one; just know it works before you do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of that is a problem, the whole thing is a &lt;code&gt;Request&lt;/code&gt; to a &lt;code&gt;Response&lt;/code&gt; and a JSON file.&lt;/p&gt;

&lt;p&gt;The repo is &lt;a href="https://github.com/ibrahimhajjaj/recourse" rel="noopener noreferrer"&gt;github.com/ibrahimhajjaj/recourse&lt;/a&gt;, MIT. There is a live demo answering from its own documentation at &lt;a href="https://recourse-demo.ibrhajjaj.workers.dev" rel="noopener noreferrer"&gt;recourse-demo.ibrhajjaj.workers.dev&lt;/a&gt; if you want to try breaking it before you read any code.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>typescript</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why your app still says connected after the network is gone</title>
      <dc:creator>Ibrahim Hajjaj</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ibrahimwithi/why-your-app-still-says-connected-after-the-network-is-gone-5a0n</link>
      <guid>https://dev.to/ibrahimwithi/why-your-app-still-says-connected-after-the-network-is-gone-5a0n</guid>
      <description>&lt;p&gt;Two phones, one shared list. I add an item on the first phone. The second phone does not get it. Both phones show a green pill that says &lt;strong&gt;synced&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is worse than an error. An error tells you to try something. A confident green pill tells you the other person is ignoring you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the socket does not know
&lt;/h2&gt;

&lt;p&gt;The app is offline-first on &lt;a href="https://www.powersync.com/" rel="noopener noreferrer"&gt;PowerSync&lt;/a&gt;, and the pill was reading PowerSync's own connection state, which comes from the socket.&lt;/p&gt;

&lt;p&gt;A socket learns it is dead in one of two ways: the other end closes it cleanly, or a keepalive eventually fails to come back.&lt;/p&gt;

&lt;p&gt;The clean close is the case everyone tests. Kill the server, watch the client flip to offline, done.&lt;/p&gt;

&lt;p&gt;The other case is the one phones live in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mobile data toggled off while the radio stays registered&lt;/li&gt;
&lt;li&gt;a handoff to a cell that never completes&lt;/li&gt;
&lt;li&gt;a socket that comes back from a long background period holding a file descriptor pointing at nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing closes. No FIN arrives. From the client's side the connection is still open, and it stays open until the library's own heartbeat times out, which is measured in tens of seconds because a shorter one would burn battery reconnecting every time somebody walks into a lift.&lt;/p&gt;

&lt;p&gt;For that whole window the indicator says synced and nothing syncs. And because the reconnect logic only fired once the engine admitted it was disconnected, no reconnect was kicked either. The app sat there, sure of itself, doing nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix I had already shipped, which made this worse
&lt;/h2&gt;

&lt;p&gt;Two weeks earlier I had fixed a different, real complaint. Bringing the app back to the foreground briefly drops and re-establishes the socket, so the pill flashed &lt;strong&gt;offline&lt;/strong&gt; for a moment on every app switch. That flicker is exactly the jank you never see in apps people like, so I debounced it: hold the current state and only admit offline if the socket is still down after a grace window, with a reconnect inside the window cancelling the pending flip. Connections still reflect immediately, only disconnections wait.&lt;/p&gt;

&lt;p&gt;Good fix. It also means the pill is now structurally slow to deliver bad news, sitting on top of a signal that was already slow to deliver bad news.&lt;/p&gt;

&lt;p&gt;The two requirements pull opposite ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;do not say offline for a blip that is about to resolve&lt;/li&gt;
&lt;li&gt;do say offline the instant the connection is actually gone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You cannot satisfy both from one signal. You need a second one that is faster and independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second signal is a boolean that is allowed to be null
&lt;/h2&gt;

&lt;p&gt;The OS knows about the network before the socket does. &lt;code&gt;expo-network&lt;/code&gt; exposes &lt;code&gt;isConnected&lt;/code&gt; and &lt;code&gt;isInternetReachable&lt;/code&gt;, and on the transports that populate them, they flip well before a heartbeat gives up.&lt;/p&gt;

&lt;p&gt;The trap is that both are &lt;code&gt;boolean | null&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; does not mean false. Some transports never populate them at all. Treat unknown as offline and you have built a new bug: an app that shows offline forever on hardware you do not own.&lt;/p&gt;

&lt;p&gt;So the whole decision comes down to one function that refuses to guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Only an EXPLICIT false means the OS is sure there is no internet.&lt;/span&gt;
&lt;span class="c1"&gt;// null/undefined stay "unknown", so an unknown reading never triggers&lt;/span&gt;
&lt;span class="c1"&gt;// a false offline.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;osOffline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ConnInputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;osReachable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;osConnected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;osReachable&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;osConnected&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&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;Everything else falls out of that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ConnInputs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;psConnected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;         &lt;span class="c1"&gt;// PowerSync believes its socket is up&lt;/span&gt;
  &lt;span class="nl"&gt;psFlowing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;           &lt;span class="c1"&gt;// an upload or download is in flight right now&lt;/span&gt;
  &lt;span class="nl"&gt;osReachable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// null = unknown on this transport&lt;/span&gt;
  &lt;span class="nl"&gt;osConnected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;resolveSyncPill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConnInputs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;SyncPill&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;osOffline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// the OS wins; don't trust a lagging socket&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;psConnected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offline&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;psFlowing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;syncing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;synced&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="c1"&gt;// A socket PowerSync still calls "connected" while the OS says there is no&lt;/span&gt;
&lt;span class="c1"&gt;// internet is wedged. It needs a forced bounce, not a wait on the heartbeat.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;needsForcedReconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ConnInputs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;psConnected&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;osOffline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&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;Note what &lt;code&gt;needsForcedReconnect&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; fire on. If PowerSync already admits it is disconnected, ordinary retry handles it and forcing a bounce would just fight the backoff. The forced reconnect exists for one situation only: the two signals disagreeing in the specific direction where the socket is the one that is wrong.&lt;/p&gt;

&lt;p&gt;Two things about the bounce itself, both learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate-limit it.&lt;/strong&gt; A flapping link will otherwise storm reconnects. Ten seconds between forced bounces was enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never clear the local database to fix a connection.&lt;/strong&gt; It is a tempting big hammer and it is the wrong shape entirely: a wedged socket is recovered by re-establishing the socket. Wiping the replica costs the user a full resync to fix a problem that was never in the data.&lt;/p&gt;

&lt;p&gt;Pulling this out into a module with no imports is what made it testable at all. The provider it came from cannot be instantiated outside a device. The decision can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;socket "connected" but OS says unreachable -&amp;gt; offline (was wrongly "synced")&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveSyncPill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;osReachable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offline&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;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown OS reachability (null) follows PowerSync (no false offline)&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;resolveSyncPill&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;osReachable&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="na"&gt;osConnected&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="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;synced&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;h2&gt;
  
  
  The regression that was already there
&lt;/h2&gt;

&lt;p&gt;Forty-four minutes after the fix landed, the pill went back to lying, because of a line nobody thought was about connections at all. It was the oldest line in the repository.&lt;/p&gt;

&lt;p&gt;The data layer refreshes a snapshot of everything the UI needs. That refresh had also been stamping the connection state in passing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- sync: db.connected ? 'synced' : 'offline',
&lt;/span&gt;&lt;span class="gi"&gt;+ // The pill is owned solely by applyStatus/recompute (PowerSync status + OS
+ // reachability). A data refresh must NOT restamp it from db.connected alone,
+ // that would clobber an OS-driven "offline" back to "synced" while the socket
+ // is still wedged.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line was present in the repository's first commit, and it had been correct in every commit since. It is not stale code and whoever wrote it was not careless. When the socket was the only input, stamping the pill from the socket was simply the truth written down twice.&lt;/p&gt;

&lt;p&gt;It became wrong the moment the same derived value acquired a second input. That happened in a different file, on the same afternoon, and every test stayed green.&lt;/p&gt;

&lt;p&gt;Derived state needs exactly one owner. Not "one place that mostly sets it".&lt;/p&gt;

&lt;p&gt;The same commit closed a second hole. The forced reconnect is &lt;code&gt;await disconnectSync()&lt;/code&gt; then &lt;code&gt;await connectSync()&lt;/code&gt;, and a sign-out or a component teardown can land in the gap between them, so the reconnect resurrects a connection something else just tore down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;disconnectSync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// Cleanup or a sign-out can land during the await; don't resurrect a&lt;/span&gt;
&lt;span class="c1"&gt;// connection the teardown/auth-change just tore down.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;disposed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;currentUserId&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;connectSync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;await&lt;/code&gt; in a lifecycle-bound async function is a place the world is allowed to change underneath you. Re-check your preconditions after it, not just before.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three parts worth stealing
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;When a signal is structurally slow, add a faster independent one rather than tuning the slow one.&lt;/strong&gt; No timeout value would have fixed the heartbeat, because the heartbeat is long for a good reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;null&lt;/code&gt; is not &lt;code&gt;false&lt;/code&gt;.&lt;/strong&gt; A tri-state input needs a tri-state decision. Only explicit evidence of absence counts as evidence; unknown stays unknown and defers to whatever else you have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One owner per piece of derived state.&lt;/strong&gt; Adding an input to a derived value silently promotes every existing writer of that value into a potential bug. The writer did not change and was not wrong when it was written; the meaning of what it was writing changed underneath it. Nothing in the type system or the tests notices, because a two-state read of a now-tri-state world still typechecks and still passes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The app is &lt;a href="https://getpsst.app" rel="noopener noreferrer"&gt;Psst&lt;/a&gt;, shared lists on iOS and Android. The indicator is honest now, which mostly means it tells you nothing is happening at the moment nothing is happening, instead of a minute later.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>typescript</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why changing your WordPress domain corrupts the database</title>
      <dc:creator>Ibrahim Hajjaj</dc:creator>
      <pubDate>Thu, 10 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ibrahimwithi/why-changing-your-wordpress-domain-corrupts-the-database-52b9</link>
      <guid>https://dev.to/ibrahimwithi/why-changing-your-wordpress-domain-corrupts-the-database-52b9</guid>
      <description>&lt;p&gt;You are moving a site from &lt;code&gt;example.com&lt;/code&gt; to &lt;code&gt;example.org&lt;/code&gt;. You run a search and replace over the database. Most of the site comes back fine, and then the homepage is blank, or a page builder layout is empty, or a widget area has quietly vanished.&lt;/p&gt;

&lt;p&gt;This happens because a WordPress database is not a pile of strings. Three separate encodings are stacked inside single column values, and a plain substitution corrupts all three in different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  The byte counts
&lt;/h2&gt;

&lt;p&gt;PHP serialization stores the length of every string alongside it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/img"&lt;/span&gt;&lt;span class="p"&gt;;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;s:23&lt;/code&gt; is a byte count. Replace &lt;code&gt;example.com&lt;/code&gt; with &lt;code&gt;a-much-longer-domain.com&lt;/code&gt; as plain text and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;23&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://a-much-longer-domain.com/img"&lt;/span&gt;&lt;span class="p"&gt;;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The string is now 36 bytes and still claims to be 23. &lt;code&gt;unserialize()&lt;/code&gt; returns &lt;code&gt;false&lt;/code&gt;, WordPress gets nothing back where it expected an array, and whatever read that option silently renders empty.&lt;/p&gt;

&lt;p&gt;The fix is not to recalculate the numbers with a regex. It is to never work on the serialized text at all: unserialize, walk the structure, replace inside the leaf strings, then re-serialize and let PHP write the lengths.&lt;/p&gt;

&lt;p&gt;One detail matters when you do this. Unserializing attacker-controlled or just old data can instantiate objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;unserialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'allowed_classes'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&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;&lt;code&gt;allowed_classes =&amp;gt; false&lt;/code&gt; turns every serialized object into a &lt;code&gt;__PHP_Incomplete_Class&lt;/code&gt; instead of constructing it. You are reading arbitrary rows out of a database you may not have written. Do not construct their objects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JSON inside the serialization
&lt;/h2&gt;

&lt;p&gt;Page builders make this worse. Elementor stores an entire layout as a JSON document, and then stores that JSON document as a string inside a serialized value. So the structure is: serialized PHP, containing a string, containing JSON, containing your URLs.&lt;/p&gt;

&lt;p&gt;Unserializing gets you the JSON as one opaque string. Your replacement runs against it as text, which brings back the byte-count problem one level down, and it also misses matches entirely, for a reason that catches almost everybody.&lt;/p&gt;

&lt;h2&gt;
  
  
  The escaped slashes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;json_encode()&lt;/code&gt; escapes forward slashes by default. So a URL that reads &lt;code&gt;https://example.com&lt;/code&gt; in the browser is stored as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https:\/\/example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search for &lt;code&gt;https://example.com&lt;/code&gt; and you match nothing. The URL is right there, visible in the row, and your query returns zero results.&lt;/p&gt;

&lt;p&gt;You cannot fix this by searching for the escaped form instead, because now you are back to substituting text inside a JSON string inside a serialized value, breaking the outer byte count and possibly the inner escaping too.&lt;/p&gt;

&lt;p&gt;You have to decode the JSON, walk it, replace in the leaves, and re-encode. And when you re-encode, the escaping has to come back the way you found it, because rewriting a row's encoding style is a change you did not ask for and cannot see in a diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;JSON_PRESERVE_ZERO_FRACTION&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'\\/'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$flags&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="no"&gt;JSON_UNESCAPED_SLASHES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;strpos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'\\u'&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$flags&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="no"&gt;JSON_UNESCAPED_UNICODE&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;Read that the right way round. It does not decide how slashes &lt;em&gt;should&lt;/em&gt; be escaped. It looks at what the original value did and matches it. If the stored JSON had &lt;code&gt;\/&lt;/code&gt; in it, the output keeps &lt;code&gt;\/&lt;/code&gt;. If it had &lt;code&gt;\uXXXX&lt;/code&gt; unicode escapes, the output keeps those too. Whatever wrote that row gets its own convention back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing when to refuse
&lt;/h2&gt;

&lt;p&gt;The hard part is not the replacing. It is noticing when a round-trip through &lt;code&gt;json_decode()&lt;/code&gt; and &lt;code&gt;json_encode()&lt;/code&gt; would not be lossless, and stopping.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Large integers.&lt;/strong&gt; JSON has no integer type distinct from float, and PHP's decoder will happily turn a 17-digit order ID into a float and hand it back to you rounded. That is silent data loss in a WooCommerce table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate keys.&lt;/strong&gt; A JSON object with the same key twice is legal to parse and impossible to represent in a PHP array. Decoding keeps the last one. Re-encoding writes a document that is missing data the original had.&lt;/p&gt;

&lt;p&gt;Both are detectable before you touch anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nb"&gt;preg_match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'/[0-9]{16}/'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json_has_duplicate_keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// do not round-trip this value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When either is true the right behaviour is to skip the row and say so, rather than write a subtly different document and call it success. Every skip carries a reason:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SKIP_MALFORMED_SERIALIZED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'malformed_serialized'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SKIP_DEPTH_EXCEEDED&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'depth_exceeded'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="no"&gt;SKIP_LOSSY_JSON&lt;/span&gt;           &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'lossy_json'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tool that reports "42 rows changed, 3 skipped because a lossless round-trip was not possible" is more useful than one that reports 45 changed, because the second one is lying in a way you will discover months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Base64, briefly
&lt;/h2&gt;

&lt;p&gt;Some plugins base64 the values they store. Same shape of problem: decode, replace, re-encode, and only when the decode is unambiguous. Strict mode alone is not enough, because plenty of ordinary short strings decode without complaint. The test that actually works is a round-trip:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;base64_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;base64_encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$decoded&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If re-encoding the decoded bytes does not reproduce the original character for character, it was not canonical base64 and you should leave it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for doing it at all
&lt;/h2&gt;

&lt;p&gt;Three practical conclusions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Preview before writing.&lt;/strong&gt; Every one of these failures is invisible at the moment it happens and expensive later. If a tool cannot show you the matches grouped by table and column, with the changed characters marked, before anything is written, you are finding out what it did by browsing your own site afterwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snapshot the affected rows.&lt;/strong&gt; Not a full database dump, which nobody takes for a search and replace. Just the rows about to change, so undo is one operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never write to the user tables.&lt;/strong&gt; Not "excluded by default", which is a checkbox somebody will untick at 2am. &lt;code&gt;wp_users&lt;/code&gt; and &lt;code&gt;wp_usermeta&lt;/code&gt; should be structurally unreachable, because a replacement that touches them can lock you out of the site you are in the middle of fixing.&lt;/p&gt;

&lt;p&gt;The implementation of all of this is GPLv2 at &lt;a href="https://github.com/ibrahimhajjaj/lucid-search-replace" rel="noopener noreferrer"&gt;github.com/ibrahimhajjaj/lucid-search-replace&lt;/a&gt;, and it is free on &lt;a href="https://wordpress.org/plugins/lucid-search-replace/" rel="noopener noreferrer"&gt;wordpress.org&lt;/a&gt;. I found the escaped-slash case the way everyone finds it: staring at a URL that was plainly visible in the row while the search returned zero results, convinced for most of an evening that I had lost my mind rather than that &lt;code&gt;json_encode&lt;/code&gt; had quietly put a backslash in front of every forward slash.&lt;/p&gt;

&lt;p&gt;That is the one to check first in whatever tool you already use. Put a page builder URL in the search box. If it finds nothing while you can see the URL sitting in the row, that tool is not reading the JSON, and the next thing it does to your database will be worse than finding nothing.&lt;/p&gt;

</description>
      <category>wordpress</category>
      <category>php</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your benchmark's single score is a weighting nobody agreed to</title>
      <dc:creator>Ibrahim Hajjaj</dc:creator>
      <pubDate>Mon, 07 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ibrahimwithi/your-benchmarks-single-score-is-a-weighting-nobody-agreed-to-5ge7</link>
      <guid>https://dev.to/ibrahimwithi/your-benchmarks-single-score-is-a-weighting-nobody-agreed-to-5ge7</guid>
      <description>&lt;p&gt;Disclosure before anything else, because it is the thing that should make you read the rest suspiciously: I sell one of the nine plugins in the benchmark this post is about. It is currently first on the overall board and second on the speed board, which turns out to be the point.&lt;/p&gt;

&lt;p&gt;I built the benchmark to answer a simple question. Which WordPress backup plugin is fastest? Two plugins get byte-identical fixed snapshots on identical sites, backup and restore run in containers with no outbound network, each pairing does a warm-up and three measured runs, and the results feed an Elo rating.&lt;/p&gt;

&lt;p&gt;The measuring was the easy part. The hard part was working out whether the number at the top of the page was allowed to exist at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two axes disagree, and not by accident
&lt;/h2&gt;

&lt;p&gt;Every run produces two things worth comparing: how long the backup took, and how big the archive came out. Rank the field by each and you get two orders.&lt;/p&gt;

&lt;p&gt;Kendall's tau between them is &lt;strong&gt;-0.50&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is not "unrelated". That is actively opposed. Across the whole field, the plugins that finish fastest write the biggest archives. Which, stated that way, is obvious: compression costs CPU time, and skipping it buys you seconds and costs you bytes. What is less obvious is what it does to a leaderboard.&lt;/p&gt;

&lt;p&gt;If I rank by speed, I am partly ranking by who compresses least. Not as a quirk of one plugin. As a rule, across nine of them.&lt;/p&gt;

&lt;p&gt;Six of the nine are Pareto-optimal: no other plugin beats them on both speed and size at once. Only three are beaten on both axes and can be honestly called worse. For the other six, "better" is a question about your constraint, not about the software.&lt;/p&gt;

&lt;p&gt;So any single ranking number I publish is a weighting between two axes that disagree. I would be picking that weighting on your behalf, applying it to your decision, and not telling you I had done it.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is settled ground, and someone already wrote the rules
&lt;/h2&gt;

&lt;p&gt;I went looking for whether serious benchmark bodies had a position on composite scores. They do, and it is stricter than I expected.&lt;/p&gt;

&lt;p&gt;SPEC's &lt;a href="https://www.spec.org/fairuse/" rel="noopener noreferrer"&gt;Fair Use rules&lt;/a&gt; have a whole section on what they call a &lt;em&gt;derived value&lt;/em&gt;: any number that is a function of a benchmark metric plus something else. Composites are explicitly allowed. What is not allowed is presenting one as the benchmark's own metric.&lt;/p&gt;

&lt;p&gt;Their worked example is close enough to my problem to sting. A gaming society builds a composite from a weighted subset of one SPEC benchmark plus a weighted subset of another, and calls it "GamePerfMark". SPEC's ruling: the composite may be useful and interesting, the weighting and the subsetting were done by the society rather than by SPEC, and calling it "SPECgame" would be a violation.&lt;/p&gt;

&lt;p&gt;Two other rules in the same document do real work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The basis for comparison must be stated.&lt;/strong&gt; Not "the fastest". The fastest &lt;em&gt;among what&lt;/em&gt;, measured &lt;em&gt;how&lt;/em&gt;, retrieved &lt;em&gt;when&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Several benchmarks define &lt;strong&gt;required metrics&lt;/strong&gt; that must be quoted alongside any headline figure, so the flattering number cannot travel on its own.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is about honesty in the abstract. It is a description of how a number gets misused once it leaves the page it was measured on, written by people who watched it happen for thirty years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second place a number gets overconfident
&lt;/h2&gt;

&lt;p&gt;Ranking is one problem. The error bar next to the ranking is another, and it took a real bug to show me.&lt;/p&gt;

&lt;p&gt;The confidence interval started as the textbook approximation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CI = 1.96 * (400 / sqrt(n))      n = battles played

    5 battles  -&amp;gt;  +/- 351
   50 battles  -&amp;gt;  +/- 111
  200 battles  -&amp;gt;  +/-  55
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;More battles, tighter interval. That formula assumes each battle is an independent observation.&lt;/p&gt;

&lt;p&gt;Then I looked at a record where 24 of 40 battles were failures, and every one of the 24 was the same fault reproduced from one bug.&lt;/p&gt;

&lt;p&gt;That is one fact about the plugin, observed 24 times. It is not 24 facts. Feed it in as 24 and &lt;code&gt;sqrt(n)&lt;/code&gt; hands you an interval that claims a precision the evidence does not have.&lt;/p&gt;

&lt;p&gt;The fix is the cluster-sampling design effect, taken at its most conservative: outcomes sharing a root cause are treated as perfectly correlated, so a cluster of any size counts once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;effectiveSampleSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;battleCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;clusterSizes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;battleCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;effective&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;battleCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;clusterSizes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;effective&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// Never claim fewer observations than the number of distinct things seen.&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;effective&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;battleCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clusterSizes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;1&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;Failures are grouped by harness version, hosting tier and phase. Forty battles that collapse to three effective observations take the interval from ±124 to ±453. Wider, which is the entire point, and wider by an amount you can check: &lt;code&gt;1.96 * (400 / sqrt(3))&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The part I want to be clear about, because it is the part that makes this honest rather than convenient: &lt;strong&gt;the rating itself does not move.&lt;/strong&gt; The losses are real, the plugin earned them, and nothing about the cluster makes them count for less. Only the stated uncertainty widens, and it widens against the plugin's own record looking decisive.&lt;/p&gt;

&lt;p&gt;The test that guards it is a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;widens the interval rather than narrowing it&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;honest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateConfidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;effectiveSampleSize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;naive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateConfidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;honest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;naive&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;h2&gt;
  
  
  What that costs the person who built it
&lt;/h2&gt;

&lt;p&gt;The board carries two Elo columns, because of everything above. One for speed, one overall. On speed my plugin, SafeGuard, is &lt;strong&gt;second at 1320&lt;/strong&gt;. All-in-One WP Migration is first at 1465, and it is first by a distance I am not going to argue with.&lt;/p&gt;

&lt;p&gt;Now the part I could have quietly left out. On the &lt;em&gt;overall&lt;/em&gt; column SafeGuard is first, at 1427 against WPvivid's 1256, and All-in-One, the plugin that just beat me on speed, is fourth at 1148. That 171-point overall gap is the number I would use if I were selling. My interval is ±124 and it still overlaps second, so I am not going to call that settled either.&lt;/p&gt;

&lt;p&gt;That inversion is this entire post in one row. The fastest plugin in the arena is fourth overall, and the rank correlation between the two axes is exactly -0.50. Whichever column you sort on, you are quietly sorting against the other one.&lt;/p&gt;

&lt;p&gt;Both figures are true. They are on the same page, in the same row, and which one you meet first is a design decision I made. That is the entire argument of this post, aimed at myself: a leaderboard is an editorial act, and the only defence is showing the reader the axis you sorted on and the one you did not.&lt;/p&gt;

&lt;p&gt;I also had to check whether a single rating was even valid here, because Elo assumes transitivity: if A beats B and B beats C, A should beat C. When that fails, ratings start reflecting who happened to be scheduled against whom. Measured across the pairwise tier medians, the cyclic component sits at 0.48 of the transitive one for speed and 0.52 for size. Both comfortably below 1, so ranking each axis on its own holds up. The rating system was not the problem. The single number on top of it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things to check in any benchmark you read
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the headline one number, and does the page say what it weighted?&lt;/strong&gt; If it is a composite and the weighting is not stated, you are reading somebody's opinion with a decimal point on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do the axes agree?&lt;/strong&gt; If the benchmark reports more than one thing, ask whether they move together. If they move against each other, ranking by one is silently ranking by the inverse of the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the error bar assume independence it does not have?&lt;/strong&gt; A record where one bug decided half the results is one observation wearing a large number. And check the arithmetic while you are there, then check your check. Writing this post I decided a figure on my own methodology page was stale, an interval of ±453 where I had worked out the formula gives ±190, and I said so here. I was wrong. The page was right. The effective sample size is three, not seventeen, and ±453 is what the code produces; ±190 came from a unit-test fixture I had read instead of a function I could have run. Reading the formula told me one thing and running it told me another, and only one of those is evidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The benchmark is at &lt;a href="https://backuparena.com" rel="noopener noreferrer"&gt;backuparena.com&lt;/a&gt;. 581 completed runs, 180 scored, 401 excluded, and the methodology page carries all of the above in more detail than this post. I should be precise about that exclusion number rather than let it sound braver than it is: 360 of the 401 are simply superseded by a later run of the same pairing, which is bookkeeping. Forty-one were excluded for cause. Every one of the 401 is browsable at &lt;a href="https://backuparena.com/battles?status=excluded" rel="noopener noreferrer"&gt;/battles?status=excluded&lt;/a&gt;, and opening any row shows the reason it was thrown out.&lt;/p&gt;

&lt;p&gt;The largest of those is worth stating because it is the kind of fault that flatters nobody. On the media-heavy profile our archive lister read one part of a multi-part archive at a time, so two plugins recorded 28,801 upload files against 30,582 for every other plugin. A silent undercount, penalising exactly the two plugins that happened to split their archives, and nothing about the numbers looked wrong.&lt;/p&gt;

&lt;p&gt;The code is not public, and that is a real limitation rather than an oversight. Every claim above is a measurement I can show you the log for. It is not one you can rerun yourself, and I would rather write that sentence than have somebody else write it for me.&lt;/p&gt;

</description>
      <category>performance</category>
      <category>testing</category>
      <category>programming</category>
      <category>wordpress</category>
    </item>
  </channel>
</rss>
