<?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: pickuma</title>
    <description>The latest articles on DEV Community by pickuma (@pickuma).</description>
    <link>https://dev.to/pickuma</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%2F3926669%2Fb3923c39-364a-4953-b8f7-aa962d6419e0.jpg</url>
      <title>DEV Community: pickuma</title>
      <link>https://dev.to/pickuma</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pickuma"/>
    <language>en</language>
    <item>
      <title>Zero Social Referrers in 30 Days: What We Changed in the navigator.share Payload Across 35 Browser Games</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:06:39 +0000</pubDate>
      <link>https://dev.to/pickuma/zero-social-referrers-in-30-days-what-we-changed-in-the-navigatorshare-payload-across-35-browser-1b1o</link>
      <guid>https://dev.to/pickuma/zero-social-referrers-in-30-days-what-we-changed-in-the-navigatorshare-payload-across-35-browser-1b1o</guid>
      <description>&lt;p&gt;Thirty days, 35 browser games, one share button per game, and the referrer breakdown in Cloudflare Web Analytics contained no social host at all. Not a low number. Nothing. The button was not broken in any way that showed up: it opened the OS share sheet on the machine we developed on, it wrote to the clipboard, and the console stayed clean.&lt;/p&gt;

&lt;p&gt;That gap — the button works, the loop does not — turned out to have two unrelated causes stacked on top of each other. One is a measurement problem that makes "zero referrers" much less informative than it looks. The other is a real bug that silenced the button on the platform where most of the traffic is. We fixed both. We have not yet measured whether the fix produces shares, and the last section says so plainly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "zero social referrers" actually measured
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Referer&lt;/code&gt; header is a weak instrument for exactly this question. X wraps outbound links in &lt;code&gt;t.co&lt;/code&gt; and its mobile apps commonly send no referrer at all. In-app webviews — Instagram, TikTok, most embedded browsers — frequently omit it. Every share that lands in a DM, an iMessage thread, a WhatsApp group, or a Discord channel arrives as direct traffic. Those are the places a game link actually travels.&lt;/p&gt;

&lt;p&gt;So an empty social row is consistent with two very different worlds:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Nobody shared anything.&lt;/li&gt;
&lt;li&gt;People shared, and the referrer was stripped before it reached us.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We could not tell those apart, which means the 30-day figure that started this work was never evidence that the share loop failed. It was evidence that we had no way to observe it. If you are looking at a similar dashboard, resolve that first, because the two worlds call for opposite responses.&lt;/p&gt;

&lt;p&gt;The only fix is to put an identifier inside the payload you hand to the user, not to rely on what the receiving platform decides to forward. We append a short suffix to the shared URL. That creates a real tension the rest of this article has to work around: the thing that makes a share measurable is a link, and a link is the part platforms downrank and users skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why an emoji grid is a different object than a score sentence
&lt;/h2&gt;

&lt;p&gt;Our original share text was a templated sentence — the game name, the score, an exclamation mark, and a URL. Compare that to the format everyone started copying after Wordle's grid spread through January 2022: six lines of colored squares and no link.&lt;/p&gt;

&lt;p&gt;The grid is not better copy. It is a different kind of object, and four properties do the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It carries its own rendering.&lt;/strong&gt; The squares are plain Unicode (U+1F7E9 and friends, added in Emoji 12.0 in 2019) and ship in the system fonts on iOS, Android, Windows and macOS. Pasted into any client, it renders. No Open Graph fetch, no crawler, no card, no dependency on the platform choosing to unfurl your domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is spoiler-safe.&lt;/strong&gt; It shows the shape of an attempt without the answer, so posting it costs the sender nothing socially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is comparable.&lt;/strong&gt; Every player who posts one that day is describing the same puzzle on the same axis. That turns a post into a thread — the reply is "4/6 here," not silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is reproducible.&lt;/strong&gt; The format is identical across senders, so it reads as a convention rather than as an ad.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"I scored 4,820 in Tile Cascade!" fails all four. It renders as a link card or as nothing. It has no shared reference frame — 4,820 against what? There is no reply available except congratulations. And a bare number next to a URL is the exact shape of promotional copy, so it gets read as promotional copy.&lt;/p&gt;

&lt;p&gt;The distinction that mattered for us: a grid encodes a &lt;strong&gt;comparable state&lt;/strong&gt;, a sentence encodes a &lt;strong&gt;claim&lt;/strong&gt;. Claims need the reader to trust the sender. Comparisons only need a shared axis.&lt;/p&gt;

&lt;p&gt;Which exposes the part that is not a formatting change at all. Our 35 games were free-play with random seeds. There is no shared axis, so there is nothing for a grid to encode — any grid we generated would have been decoration on a claim. Shipping the format required shipping a &lt;strong&gt;daily seed&lt;/strong&gt; first: one deterministic instance per game per UTC day, derived from the date so every player gets the same board. That is an architectural change to the game loop, not a change to a share button.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not &lt;code&gt;await&lt;/code&gt; anything between the click handler and &lt;code&gt;navigator.share()&lt;/code&gt;. The Web Share API requires transient user activation. Chromium's activation window is about five seconds, so a quick fetch usually survives it on desktop Chrome. Safari is stricter — a single &lt;code&gt;await&lt;/code&gt; before the call is enough to lose activation, and &lt;code&gt;share()&lt;/code&gt; rejects with &lt;code&gt;NotAllowedError&lt;/code&gt;. If your catch block falls through to &lt;code&gt;navigator.clipboard.writeText()&lt;/code&gt;, that call needs activation and document focus too, so it fails for the same reason and you get a button that does nothing at all.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The bug that silenced the button on iOS Safari
&lt;/h2&gt;

&lt;p&gt;Our handler minted the share identifier server-side. The sequence was: click, &lt;code&gt;await fetch('/api/share-id')&lt;/code&gt;, build the string, call &lt;code&gt;navigator.share()&lt;/code&gt;. On desktop Chrome this worked, which is why it shipped.&lt;/p&gt;

&lt;p&gt;On iOS Safari it did not. The &lt;code&gt;await&lt;/code&gt; cost us the transient activation, &lt;code&gt;share()&lt;/code&gt; rejected, the fallback clipboard write rejected for the same reason, and the &lt;code&gt;catch&lt;/code&gt; block was empty. No sheet, no toast, no console error a user would ever see. Mobile is where a game gets shared, so the loop was effectively dead on the platform that mattered while looking healthy in development.&lt;/p&gt;

&lt;p&gt;The fix has three parts, and none of them are clever:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Derive the share identifier &lt;strong&gt;client-side&lt;/strong&gt; from the daily seed and the result. No network call in the handler, so nothing to await.&lt;/li&gt;
&lt;li&gt;Feature-detect with &lt;code&gt;navigator.canShare?.({ text })&lt;/code&gt; before calling, and fall back deliberately rather than by exception. Desktop Firefox has no &lt;code&gt;navigator.share&lt;/code&gt; at all — check current support rather than trusting this sentence.&lt;/li&gt;
&lt;li&gt;Make the fallback visible: render the text in a selectable block with an explicit copied state, instead of a silent clipboard write that can fail without telling anyone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What we changed, and what we have not measured
&lt;/h2&gt;

&lt;p&gt;Shipped: a UTC daily seed per game, a grid-style result block built from the day's board, share text that leads with the grid and puts the URL on its own last line, a synchronous share handler, and a visible copy fallback.&lt;/p&gt;

&lt;p&gt;Not measured: whether any of it produces referrers. This article is the diagnosis and the change, not the result. We also did not test Android Chrome Custom Tabs, did not verify how the fallback link renders as a Bluesky card, and have not checked whether the daily seed &lt;strong&gt;reduces&lt;/strong&gt; total sessions — capping a free-play game at one board a day is a real cost, and it is plausible the trade is negative.&lt;/p&gt;

&lt;p&gt;If you cannot ship a shared daily instance, do not copy the grid. Ship a decent per-game &lt;code&gt;og:image&lt;/code&gt; and accept ordinary link-card sharing; a grid with no common axis is noise with extra steps. The condition that flips it is exactly that axis — the moment every player is solving the same thing on the same day, the format starts doing work that copy cannot.&lt;/p&gt;

&lt;p&gt;And given how much of this came down to not being able to observe our own traffic: a channel you own answers the question directly. Referrers are optional; a subscriber list is not.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/navigator-share-payload-35-browser-games/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>701 Bad Internal Links Before 49 Good Ones: Why Deterministic Anchor Matching Fails</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:05:23 +0000</pubDate>
      <link>https://dev.to/pickuma/701-bad-internal-links-before-49-good-ones-why-deterministic-anchor-matching-fails-2b92</link>
      <guid>https://dev.to/pickuma/701-bad-internal-links-before-49-good-ones-why-deterministic-anchor-matching-fails-2b92</guid>
      <description>&lt;p&gt;We maintain a 269-article editorial corpus and wanted internal links without hand-placing every one. The first script was the obvious design: build a phrase list from every article's title, its &lt;code&gt;tools&lt;/code&gt; frontmatter, and its keywords; walk each MDX body; wrap the first occurrence of each phrase in a link to the matching slug. 812 phrases, 269 files, one pass, 750 insertions.&lt;/p&gt;

&lt;p&gt;We kept 49 of them.&lt;/p&gt;

&lt;p&gt;This is where the other 701 went, why adding matching rules does not rescue the design, and what the second version changed — which turned out not to be precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the 701 went
&lt;/h2&gt;

&lt;p&gt;Every insertion was reviewed by hand against the rendered page. The rejections sorted into five buckets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rejection reason&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anchor landed inside a code fence or inline code&lt;/td&gt;
&lt;td&gt;214&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phrase matched a different sense of the word&lt;/td&gt;
&lt;td&gt;168&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target article never discussed the matched claim&lt;/td&gt;
&lt;td&gt;141&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Page already linked to that slug&lt;/td&gt;
&lt;td&gt;96&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anchor landed in a heading or the first two paragraphs&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cleanest example is the phrase &lt;code&gt;Cursor&lt;/code&gt;. It is an editor we write about, and it is also &lt;code&gt;cursor: pointer&lt;/code&gt; in every CSS snippet we ship, &lt;code&gt;cursor&lt;/code&gt; in three paragraphs about Postgres keyset pagination, and a substring of &lt;code&gt;cursor&lt;/code&gt; in DOM API prose. Case sensitivity does not save you: our sentences capitalize at the start, and type names in code are capitalized too. A tokenizer that respects word boundaries and skips fenced blocks removes most of the 214 and some of the 168. It removes none of the 141.&lt;/p&gt;

&lt;p&gt;That third bucket is the one that matters, because it is the one that looks correct in the diff. The phrase was real prose, the link went to a real article, and the article had nothing to say about the sentence it was attached to. Of those 141, 118 came from phrases that appear in more than 20 of our 269 articles — the generic middle of a title, the part chosen for search rather than for meaning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two insertions in the first run wrote a link inside an existing markdown link. MDX compiled both files without error and rendered literal brackets around a working link. A third wrote into a JSX attribute string and silently changed a component prop. If you do this, compile every touched file &lt;em&gt;and&lt;/em&gt; diff the rendered text, not the source. A green build is not evidence the insertion was structurally valid.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why more rules do not fix it
&lt;/h2&gt;

&lt;p&gt;Each rule you add is a filter, not a signal. Excluding code, excluding headings, requiring word boundaries, capping links per page — all of these subtract bad candidates. None of them add information about whether the link is &lt;em&gt;useful&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The defect is in what a phrase match proves. It proves a term occurs on both pages. It does not prove that the claim being made in this sentence is one the target page supports. Those are different questions, and only the second one describes what a reader gets from clicking.&lt;/p&gt;

&lt;p&gt;Titles are the worst possible source for anchors, for a reason specific to how titles are written. A title is a noun phrase optimized to be searched for, which means it is composed of the vocabulary most common in its topic. Feeding those phrases back into a matcher is close to matching on the corpus's own stopwords.&lt;/p&gt;

&lt;h2&gt;
  
  
  Link from claims, not from terms
&lt;/h2&gt;

&lt;p&gt;We already generate three to five one-sentence checkable claims per article into a committed JSON file — roughly 1,000 claim strings across the corpus. The second version used those as the link targets instead of titles.&lt;/p&gt;

&lt;p&gt;The pipeline: embed every body sentence and every claim; propose a link where cosine similarity clears 0.62 and the two articles do not share both category and tool list (that exclusion exists to stop four hub articles absorbing most of the links); then send every survivor through a single yes/no gate — does the target claim substantiate this sentence?&lt;/p&gt;

&lt;p&gt;1,043 candidates cleared the threshold. 128 survived the gate. 61 shipped after human review.&lt;/p&gt;

&lt;p&gt;Read those numbers carefully, because the obvious reading is wrong. Candidate-level precision barely moved: 49 of 750 is 6.5 percent, 61 of 1,043 is 5.8 percent. The win is entirely in the review pile. 128 diffs is an afternoon. 750 diffs is not something you will do twice, which means version one was going to be abandoned rather than corrected.&lt;/p&gt;

&lt;p&gt;The embedding step is not doing semantic work you could not get from the gate alone. It exists as a cost filter: about 32,000 body sentences against 1,000 claims is roughly 32 million pairs, and the gate cannot run on that. If your corpus is small enough that the full cross product is affordable, skip the embeddings.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did not measure, and what we would do instead
&lt;/h2&gt;

&lt;p&gt;We have not measured whether the 61 links changed anything in Search Console. Our indexed-URL count was 37 on 2026-08-17, our snapshot series is weekly, and 434 articles were deleted in the same window. Two data points cannot separate a linking change from a prune of that size. If someone tells you internal links moved their index coverage inside a month with a concurrent deletion, they are reading noise.&lt;/p&gt;

&lt;p&gt;What we would tell you, given the corpus size you probably have:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Under about 50 articles, do not automate this.&lt;/strong&gt; Hand-maintain a phrase-to-slug map. Ours started at 30 entries and covered most of what version one found correctly, at a fraction of the review cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The condition that flips it&lt;/strong&gt; is map maintenance exceeding roughly one new entry per published article, with real topical clusters underneath — in practice somewhere past 150 articles. Below that line the script's review cost is larger than the map's maintenance cost, and you are automating the cheaper half of the job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whatever you build, emit candidates and never edits.&lt;/strong&gt; The commit stays human. Version one wrote files directly, which is why the first thing we did after reviewing it was &lt;code&gt;git checkout .&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anchor on the sentence that asserts something, and link to the page that verifies it.&lt;/strong&gt; A useful side effect: if a target page has no checkable claim to link to, that is a signal about the target page, not about the linker.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/automated-internal-linking-deterministic-anchor-matching-failure/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
    <item>
      <title>Your Bot Filter Misses Crawlers That Send a Referer: How 234 Qualified Clicks Became 117</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 03:04:07 +0000</pubDate>
      <link>https://dev.to/pickuma/your-bot-filter-misses-crawlers-that-send-a-referer-how-234-qualified-clicks-became-117-10mp</link>
      <guid>https://dev.to/pickuma/your-bot-filter-misses-crawlers-that-send-a-referer-how-234-qualified-clicks-became-117-10mp</guid>
      <description>&lt;p&gt;Our affiliate redirect at &lt;code&gt;/go/&amp;lt;slug&amp;gt;&lt;/code&gt; classifies every click with two checks: is the &lt;code&gt;Referer&lt;/code&gt; header missing, and does the User-Agent match a crawler regex. That is the entire function.&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;isBotClick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;referer&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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;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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referer&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;true&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;CRAWLER_UA&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;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On 2026-08-18 that filter reported 234 of 506 clicks in the trailing 30 days as human. Applying one further exclusion — dropping the two countries whose pageviews carried a datacentre signature — took the same 234 down to 117. Half of everything the header check called "qualified" was a crawler that had sent a referer. Three days later the gap was wider: 273 qualified, 128 after the country cut, 145 removed.&lt;/p&gt;

&lt;p&gt;Nothing in that function is wrong as written. The problem is that what it measures — did this request arrive from a link on our own site — is not what we were reporting, which was whether a person was on the other end. Those two coincided for long enough to look like the same number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a same-origin referer stopped being evidence
&lt;/h2&gt;

&lt;p&gt;The heuristic had a real justification when it went in. &lt;code&gt;/go/&lt;/code&gt; links are same-origin with the article that contains them, and the default referrer policy in current browsers sends a referer on same-origin navigation. A reader clicking a link in an article therefore always carries one. In the first 30-day sample, 305 of 334 clicks had no referer at all — direct hits on a redirect URL nobody has a reason to type. Filtering those out moved the human share from 100% to roughly 9%, which was the right direction and a large correction.&lt;/p&gt;

&lt;p&gt;What the check cannot do is distinguish that browser from an HTTP client that fetches the article HTML, parses out the hrefs, and requests each one with &lt;code&gt;Referer: https://pickuma.com/for-dev/&amp;lt;slug&amp;gt;/&lt;/code&gt; attached. The header is set by the client. A crawler that follows internal links produces a byte-identical request. There is no server-side way to separate the two from headers alone, because a browser's referer is not a signed assertion — it is a courtesy the client chooses to extend.&lt;/p&gt;

&lt;p&gt;The UA regex has the same shape of problem. It matches self-identifying strings: &lt;code&gt;bot&lt;/code&gt;, &lt;code&gt;crawl&lt;/code&gt;, &lt;code&gt;spider&lt;/code&gt;, &lt;code&gt;slurp&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt;, &lt;code&gt;wget&lt;/code&gt;, &lt;code&gt;python-requests&lt;/code&gt;, &lt;code&gt;headless&lt;/code&gt;, &lt;code&gt;scrapy&lt;/code&gt;, &lt;code&gt;axios&lt;/code&gt;, &lt;code&gt;okhttp&lt;/code&gt;. Every entry on that list is a client that told us what it was. A scraper with a copied Chrome UA string passes both halves of the filter. The whole thing depends on the other side volunteering the truth.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If your bot flag is a single boolean written at insert time, you cannot re-run it. We could reclassify 30 days retroactively only because the same row also stores &lt;code&gt;referer&lt;/code&gt;, &lt;code&gt;country&lt;/code&gt;, and a salted &lt;code&gt;ua_hash&lt;/code&gt;. Write the raw signals; compute the verdict at read time.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The split came from the country column, not the headers
&lt;/h2&gt;

&lt;p&gt;The tell was not in the click table at all. It was in the pageview series: Singapore had accumulated 4,940 pageviews at 100% direct and 0% mobile. No population of real readers is 0% mobile, and none is 100% direct. That is a datacentre fingerprint, and it was unambiguous in a way no header was.&lt;/p&gt;

&lt;p&gt;Once we had the two-country signature, applying it to clicks was one predicate. On 2026-08-18, China alone accounted for 52% of what the header check had called qualified. The 234-to-117 collapse was almost entirely those two countries arriving with a referer we had decided to trust.&lt;/p&gt;

&lt;p&gt;The second-order damage was worse than the raw miscount. Pageviews already excluded Singapore and China; clicks did not. Any click-through rate computed across those two series divided a filtered numerator by an unfiltered denominator, and every such ratio we had looked at for weeks was off by roughly a factor of two — in the flattering direction. Two filters that disagree are worse than no filter, because the error hides inside a ratio instead of sitting in plain sight in a count.&lt;/p&gt;

&lt;p&gt;We did not redefine &lt;code&gt;qualified&lt;/code&gt;. The snapshot file now records &lt;code&gt;total&lt;/code&gt;, &lt;code&gt;qualified&lt;/code&gt;, and &lt;code&gt;qualifiedClean&lt;/code&gt; side by side, so rows written before the fix still mean what they meant when they were written. Overwriting a metric's definition in place is how you lose the ability to say when something changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks this heuristic next
&lt;/h2&gt;

&lt;p&gt;The country cut is blunt, and it costs us real readers in Singapore and China. We accepted that because the alternative — no comparable series at all — was worse at our volume, which is roughly 500 clicks per 30 days. Below about 50 clicks a month, a country exclusion is noise dressed up as rigour. Do not bother.&lt;/p&gt;

&lt;p&gt;Above that, the thing to reach for is a classification made at the edge before your handler runs. Cloudflare exposes bot scoring to Workers through &lt;code&gt;request.cf.botManagement&lt;/code&gt;, which is a better input than anything you can reconstruct from request headers. We have not moved to it, and the condition that would flip us is plan availability: the useful part of that field set is a paid Bot Management feature, and we did not verify what our own plan returns. Check that before designing around it rather than after.&lt;/p&gt;

&lt;p&gt;Boundaries on what we actually measured. We did not identify the crawlers. &lt;code&gt;ua_hash&lt;/code&gt; is salted over UA and IP, so we can count distinct clients but cannot name one or reverse it. We store &lt;code&gt;cf-ipcountry&lt;/code&gt;, not the source IP, so reverse-DNS verification was not possible on rows we already had. We did not test whether the same clients inflate pageviews outside the two excluded countries — they probably do, and our "clean" pageview number is therefore an upper bound, not a true one. This is one site's data over 30 days, not a general result about referer heuristics.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/referer-heuristic-double-counted-qualified-affiliate-clicks/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Cloudflare Web Analytics via GraphQL: the siteTag Filter and the Dimensions That Split Bot Traffic</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:47:03 +0000</pubDate>
      <link>https://dev.to/pickuma/cloudflare-web-analytics-via-graphql-the-sitetag-filter-and-the-dimensions-that-split-bot-traffic-1p1b</link>
      <guid>https://dev.to/pickuma/cloudflare-web-analytics-via-graphql-the-sitetag-filter-and-the-dimensions-that-split-bot-traffic-1p1b</guid>
      <description>&lt;p&gt;The Cloudflare Web Analytics dashboard reported 11,760 pageviews for this site over the 30 days ending 2026-08-21. The number we record in our weekly snapshot for the same window is 2,860. Same dataset, same account — the difference is a filter on two dimensions that the dashboard will not combine for you. The query that produces it is about twelve lines against &lt;code&gt;https://api.cloudflare.com/client/v4/graphql&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&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="n"&gt;viewer&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="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filter&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="n"&gt;accountTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;32-hex account id&amp;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="n"&gt;rumPageloadEventsAdaptiveGroups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;orderBy&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="n"&gt;count_DESC&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;filter&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="n"&gt;siteTag&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;32-hex site tag&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;datetime_geq&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-07-22T00:00:00Z"&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="n"&gt;datetime_leq&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-21T00:00:00Z"&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;span class="n"&gt;count&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;dimensions&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="n"&gt;countryName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;requestHost&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;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;Auth is a plain &lt;code&gt;Authorization: Bearer &amp;lt;token&amp;gt;&lt;/code&gt; header with an API token carrying account-level Analytics read. That is the entire surface needed to count pageviews. What follows is the part that cost us time.&lt;/p&gt;

&lt;h2&gt;
  
  
  accountTag, siteTag and requestHost are three different things
&lt;/h2&gt;

&lt;p&gt;The filter takes two 32-hex identifiers and they are not interchangeable. &lt;code&gt;accountTag&lt;/code&gt; is your Cloudflare account ID — the same value you already have in &lt;code&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt; for Wrangler. &lt;code&gt;siteTag&lt;/code&gt; is issued per Web Analytics property and is a distinct value; ours is hardcoded as a separate constant in the snapshot script precisely because it is neither the account ID nor the zone ID. If you are hunting for it, it is the same token that appears in the beacon snippet Cloudflare gives you, the &lt;code&gt;data-cf-beacon&lt;/code&gt; attribute.&lt;/p&gt;

&lt;p&gt;The third one is the surprise. A single site tag can carry more than one hostname. Our tag covers both &lt;code&gt;pickuma.com&lt;/code&gt; and &lt;code&gt;play.pickuma.com&lt;/code&gt;, a sister project on a different worker. Of the 11,760 pageviews in that window, 760 — about 6.5% — belonged to the sister site. Without grouping by &lt;code&gt;requestHost&lt;/code&gt; and discarding rows that do not match, every number you compute silently sums two properties. That error does not announce itself; it just makes your traffic look better than it is, consistently, forever.&lt;/p&gt;

&lt;p&gt;One more mechanical detail: query-level failures come back with HTTP 200 and an &lt;code&gt;errors&lt;/code&gt; array in the body. Our script checks &lt;code&gt;json.errors&lt;/code&gt; and never checks &lt;code&gt;response.ok&lt;/code&gt;, and that is deliberate — a bad &lt;code&gt;siteTag&lt;/code&gt; or a malformed datetime returns a perfectly successful HTTP response containing nothing useful. If you branch on status code you will treat a broken query as an empty week.&lt;/p&gt;

&lt;h2&gt;
  
  
  limit is a row cap and it truncates silently
&lt;/h2&gt;

&lt;p&gt;The adaptive-group selectors take &lt;code&gt;limit&lt;/code&gt; as an argument, and it caps &lt;strong&gt;returned groups, not events&lt;/strong&gt;. With a low-cardinality grouping this is invisible. Our query groups by country crossed with host, which is a few hundred rows at the outside, so &lt;code&gt;limit: 5000&lt;/code&gt; has never been close to binding.&lt;/p&gt;

&lt;p&gt;Add a path dimension and the arithmetic changes fast. This site has 289 URLs in its sitemap; crossed with roughly a hundred countries, the theoretical row count is well past 5,000 before you have added a device or referer dimension. You do not get an error when you cross the line. You get the top N rows by whatever &lt;code&gt;orderBy&lt;/code&gt; you specified and a total that is quietly short.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you sum &lt;code&gt;count&lt;/code&gt; across rows to get a total, that sum is only correct while the row count is under your &lt;code&gt;limit&lt;/code&gt;. Check the length of the returned array against the limit on every run. If they are equal, treat the total as a lower bound, not a measurement — and either raise the limit or drop a dimension. A truncated total that looks plausible is worse than an error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Which quantile fields exist: introspect, do not trust a list
&lt;/h2&gt;

&lt;p&gt;Our production query uses &lt;code&gt;count&lt;/code&gt; and two dimensions. It does not use quantiles, and we are not going to publish a field list we never exercised — that is exactly the kind of paraphrase that is wrong six months later when the schema moves.&lt;/p&gt;

&lt;p&gt;The reliable answer is introspection against your own account, because what is available varies with plan and with which RUM dataset you are actually in. Cloudflare's GraphQL endpoint answers introspection queries with the same bearer token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight graphql"&gt;&lt;code&gt;&lt;span class="k"&gt;query&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="n"&gt;__type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&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;"AccountRumPageloadEventsAdaptiveGroups"&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="n"&gt;fields&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="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&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="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ofType&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="n"&gt;name&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;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;Run that once, save the output next to your query, and you have a field list that is true for your account rather than true for someone's blog post. Two things to check while you are in there. First, whether the percentile fields you want sit on a &lt;code&gt;quantiles&lt;/code&gt; sub-selection or as flat fields — this determines whether your GraphQL selection set even parses. Second, whether the metric you are after lives on this dataset at all. The dashboard's page-load timing panel and its Core Web Vitals panel are not guaranteed to be reading the same underlying dataset, so a field being visible in the UI is not evidence that it is selectable here.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Introspection is also the cheapest way to discover the filter fields. Query &lt;code&gt;__type(name: "...Filter_InputObject")&lt;/code&gt; and read &lt;code&gt;inputFields&lt;/code&gt; — that tells you which dimensions are filterable rather than merely groupable, which is not the same set.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What this dataset structurally cannot tell you
&lt;/h2&gt;

&lt;p&gt;RUM is a browser beacon. It fires when JavaScript runs. A &lt;code&gt;curl&lt;/code&gt; loop, a Python &lt;code&gt;requests&lt;/code&gt; scraper, or any client that pulls HTML without a browser engine never enters the dataset at any percentile of any dimension. No filter you write recovers traffic that was never recorded.&lt;/p&gt;

&lt;p&gt;That sets a hard ceiling on what the country and host dimensions can do. They are useful — dropping two countries with a datacentre traffic signature took our 30-day figure from 11,760 to 2,860, and that ratio is the difference between a site that looks like it is recovering and one that is not. But it separates &lt;em&gt;JS-executing automation&lt;/em&gt; from readers. Whatever bot share you compute this way, the real share is higher.&lt;/p&gt;

&lt;p&gt;If you need per-request truth, this is the wrong instrument and no amount of schema archaeology fixes it. Put a Worker in front of the origin and log the bot score and ASN per request. The condition that flips the choice is whether you have a request-level vantage point at all: on Astro static output served by Cloudflare Static Assets, the application never sees a request line, so RUM plus GraphQL is the fallback, not the preference.&lt;/p&gt;

&lt;p&gt;The other reason to use the API rather than the dashboard is retention. We exercise 7-day and 30-day windows; the dataset is a rolling window, and a week you did not record is a week you cannot reconstruct. That is why our snapshot writes a dated row to a committed JSON file. Nothing about the query is clever — it is just run on a schedule, which the dashboard cannot do for you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/cloudflare-web-analytics-graphql-rum-pageload-events-sitetag/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
    <item>
      <title>A Jaccard Gate at 0.50 Before the Model Runs: Why We Strip Year Tokens From Article Titles</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:45:47 +0000</pubDate>
      <link>https://dev.to/pickuma/a-jaccard-gate-at-050-before-the-model-runs-why-we-strip-year-tokens-from-article-titles-2aca</link>
      <guid>https://dev.to/pickuma/a-jaccard-gate-at-050-before-the-model-runs-why-we-strip-year-tokens-from-article-titles-2aca</guid>
      <description>&lt;p&gt;The gate that decides whether this site spends a generation is 93 lines of TypeScript with no dependencies. It refuses a topic outright when Jaccard similarity against any existing title reaches &lt;strong&gt;0.50&lt;/strong&gt;, prints a warning at &lt;strong&gt;0.35&lt;/strong&gt;, and before scoring anything it drops every token matching &lt;code&gt;/^20\d\d$/&lt;/code&gt;. On the live corpus that year filter touches &lt;strong&gt;78 of 274 published titles&lt;/strong&gt; — 28% of everything on the site carries a four-digit year.&lt;/p&gt;

&lt;p&gt;That last number is the whole reason the filter exists, and the reason it cuts in two directions at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gate runs twice, and both times before something expensive
&lt;/h2&gt;

&lt;p&gt;The generator calls &lt;code&gt;assertNotDuplicate&lt;/code&gt; at two points. The first is the cheapest check in the pipeline: it runs against the topic string pulled from the candidate queue, before a prompt is even assembled. The second runs against the title the model actually produced, because a model handed a topic about connection pooling will cheerfully return an article about ORM query builders — one we already have.&lt;/p&gt;

&lt;p&gt;The cost asymmetry is the entire argument. Gate one is a &lt;code&gt;readdirSync&lt;/code&gt; over 274 files, a frontmatter slice, and a set intersection. The thing it guards is &lt;code&gt;invokeClaude(prompt, 360_000)&lt;/code&gt; — a call configured with a six-minute timeout, followed by JSON extraction, zod validation, and an MDX compile pass. You do not need the gate to be clever. You need it to be free, and to run first.&lt;/p&gt;

&lt;p&gt;This was added after a specific failure. In one day the generator produced five variants of the same tool review and three of the same benchmark, and Search Console came back with 60 pages classified as "Duplicate without user-selected canonical." Nothing in the pipeline had ever compared a proposed topic against what already existed. The queue fed it topics; it wrote them.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The tokeniser drops any word of two characters or fewer. On a corpus about developer tools that quietly deletes &lt;code&gt;S3&lt;/code&gt;, &lt;code&gt;R2&lt;/code&gt;, &lt;code&gt;B2&lt;/code&gt;, &lt;code&gt;AI&lt;/code&gt;, &lt;code&gt;UI&lt;/code&gt;, &lt;code&gt;CI&lt;/code&gt;, and &lt;code&gt;Go&lt;/code&gt; from every title it sees. "Cloudflare R2 vs S3 for Static Assets" and "Cloudflare R2 vs B2 for Static Assets" both tokenise to the same three-word set — &lt;code&gt;cloudflare&lt;/code&gt;, &lt;code&gt;static&lt;/code&gt;, &lt;code&gt;assets&lt;/code&gt; — and score &lt;strong&gt;1.00&lt;/strong&gt; against each other. Two genuinely different comparisons, one hard block. If your product names are short, this rule will bite you and the error message will look like a correct decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why the year token is stripped, and why it cuts both ways
&lt;/h2&gt;

&lt;p&gt;Take two real titles from the corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"AI Code Review Tools Compared: CodeRabbit, Greptile, and Diamond in 2026"&lt;/li&gt;
&lt;li&gt;"AI Meeting Notetakers Compared: Granola, Fathom, and Otter in 2026"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After lowercasing, stripping punctuation, dropping tokens of two characters or fewer, and removing the 25-word stop list, each reduces to six content tokens. They share exactly one: &lt;code&gt;compared&lt;/code&gt;. Union of 11, intersection of 1, so Jaccard is &lt;strong&gt;0.09&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now leave the year in. Each set grows to seven tokens, the intersection becomes &lt;code&gt;compared&lt;/code&gt; and &lt;code&gt;2026&lt;/code&gt;, and the score is 2/12 — &lt;strong&gt;0.17&lt;/strong&gt;. The same unrelated pair, scored nearly twice as high, because both titles mention a year.&lt;/p&gt;

&lt;p&gt;Title token sets are small. Six content words is typical here, so a single spurious shared token moves the score by roughly 8-9 points. With 28% of the corpus carrying a year, a proposed title that also carries one gets that free intersection against a large slice of everything you have already published. Enough of those stack up near 0.35 and the gate starts warning on articles that have nothing to do with each other — and a warning nobody trusts is a warning nobody reads.&lt;/p&gt;

&lt;p&gt;The second direction is the one that surprised us, and it is the more useful half. Strip the year and "The Best Async Standup Tools in 2025" and "The Best Async Standup Tools in 2026" become &lt;strong&gt;identical token sets&lt;/strong&gt;. Score 1.00. Hard abort. That is correct behaviour, not a bug to work around: a year-only difference is not a new article, it is an update to an existing one. The right move is editing the published post and adding a &lt;code&gt;changelog&lt;/code&gt; entry, not shipping a second URL that competes with the first for the same query.&lt;/p&gt;

&lt;p&gt;Both behaviours come from the same one-line filter. You cannot take one without the other, and you should not want to.&lt;/p&gt;

&lt;p&gt;The stop list reinforces this. It holds &lt;code&gt;best&lt;/code&gt;, &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;guide&lt;/code&gt;, &lt;code&gt;vs&lt;/code&gt;, &lt;code&gt;how&lt;/code&gt;, and &lt;code&gt;why&lt;/code&gt; — precisely the scaffolding a templated listicle title is built from. Strip that plus the year and two listicles get compared on their subject nouns alone, which is the only part that determines whether they are the same article.&lt;/p&gt;

&lt;h2&gt;
  
  
  0.50 and 0.35 are guesses that survived, and here is what we did not test
&lt;/h2&gt;

&lt;p&gt;Both thresholds were picked to fire on the failure we had actually observed, not derived from a labelled set. 0.50 blocks; 0.35 warns; the score used is the higher of the title comparison and the slug comparison, since a model sometimes keeps the topic in the slug after rewriting the title away from it.&lt;/p&gt;

&lt;p&gt;What we cannot tell you: the block rate, or the false-positive rate. The generator's catch handler increments a single &lt;code&gt;failed&lt;/code&gt; counter, and a duplicate abort and an MDX compile failure both land there identically. Nothing distinguishes them in the tally. We also did not run a full pairwise sweep across all 274 live titles for this article — the counts here are grep-verified and the two scores above are hand-computed from the tokeniser's actual rules. If you build this, add a distinct counter for gate rejections before you tune the numbers, or you will be tuning blind.&lt;/p&gt;

&lt;p&gt;The alternative worth naming is cosine similarity over embeddings of title plus description. The condition that flips the decision is the &lt;em&gt;shape&lt;/em&gt; of your duplicates. Jaccard sees shared tokens and nothing else, so it scores "Postgres Connection Pooling With PgBouncer" against "Avoiding Connection Exhaustion in Supabase" at close to zero — no overlapping content nouns — even though the two answer the same question for the same reader. If that paraphrase case is what keeps slipping through, token overlap is structurally blind to it and you need vectors, plus the storage and refresh job that come with them.&lt;/p&gt;

&lt;p&gt;If what keeps slipping through is a generator emitting five near-identical titles in one batch, Jaccard already catches it, runs offline, needs no API call, and has no index to keep in sync. That is the failure this site had. It stayed crude on purpose.&lt;/p&gt;

&lt;p&gt;One boundary to keep in view either way: this gate reads titles and slugs. Body-level duplication — two articles with different titles making the same three arguments — is invisible to it, and no threshold you pick will change that.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/jaccard-duplicate-title-gate-year-tokens/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Bing Webmaster API's 100-URL Daily Cap: 289 URLs Took Three Days and GetQueryStats Was Still Empty</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Fri, 21 Aug 2026 02:44:31 +0000</pubDate>
      <link>https://dev.to/pickuma/the-bing-webmaster-apis-100-url-daily-cap-289-urls-took-three-days-and-getquerystats-was-still-5058</link>
      <guid>https://dev.to/pickuma/the-bing-webmaster-apis-100-url-daily-cap-289-urls-took-three-days-and-getquerystats-was-still-5058</guid>
      <description>&lt;p&gt;We verified pickuma.com in Bing Webmaster Tools on 2026-08-18 by serving &lt;code&gt;BingSiteAuth.xml&lt;/code&gt;, added the &lt;code&gt;msvalidate.01&lt;/code&gt; meta tag a day later as a second signal, and started driving the Webmaster API from a scheduled script on 2026-08-19. Three days of submission state and one weekly snapshot later, this is what the API actually returned.&lt;/p&gt;

&lt;p&gt;The short version: &lt;code&gt;GetUrlSubmissionQuota&lt;/code&gt; and &lt;code&gt;SubmitUrlBatch&lt;/code&gt; work the moment verification lands. The reporting endpoints do not. On 2026-08-21, three days after verification, &lt;code&gt;GetRankAndTrafficStats&lt;/code&gt; returned exactly one row and &lt;code&gt;GetQueryStats&lt;/code&gt; returned none.&lt;/p&gt;

&lt;h2&gt;
  
  
  100 a day is a real cap, and 289 URLs took three days
&lt;/h2&gt;

&lt;p&gt;The first &lt;code&gt;GetUrlSubmissionQuota&lt;/code&gt; call came back with &lt;code&gt;DailyQuota: 100&lt;/code&gt; and &lt;code&gt;MonthlyQuota: 1300&lt;/code&gt;. After one 100-URL &lt;code&gt;SubmitUrlBatch&lt;/code&gt;, the same call returned &lt;code&gt;DailyQuota: 0&lt;/code&gt; and &lt;code&gt;MonthlyQuota: 1200&lt;/code&gt;. Two separate buckets, both decremented by the same submissions.&lt;/p&gt;

&lt;p&gt;That second number matters more than it looks. At the full 100/day rate, a 1300/month allowance is gone in 13 days. If you are planning a recurring push rather than a one-off catch-up, you are budgeting against the monthly figure, not the daily one.&lt;/p&gt;

&lt;p&gt;Our sitemap held 286 URLs when the script first ran. Submitting newest-&lt;code&gt;lastmod&lt;/code&gt;-first, with the homepage pinned to position one regardless of its date, the corpus drained like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2026-08-19: 100 URLs&lt;/li&gt;
&lt;li&gt;2026-08-20: 100 URLs&lt;/li&gt;
&lt;li&gt;2026-08-21: 89 URLs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That totals 289 rather than 286 because three articles shipped mid-drain and the sitemap grew underneath the job. The state file absorbed it without special handling: each run reads the current sitemap, subtracts everything already sent, and takes the next 100.&lt;/p&gt;

&lt;p&gt;That state file is the part worth copying. Without it, a daily cron re-sends the same first hundred URLs every morning and never reaches URL 101. Nothing tells you this is happening — the API accepts duplicate submissions and returns success, so the job looks healthy while covering 35% of the site forever.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The daily quota resets, but the monthly one is the constraint that bites. If you burn 100/day to clear a backlog, you have roughly 13 usable days per month. We did not test whether the monthly counter resets on the calendar month or on a rolling 30-day window, and the API does not say — plan for the pessimistic reading until you have watched a month boundary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Which endpoints have data on day one, and which stay empty
&lt;/h2&gt;

&lt;p&gt;This is the part the docs will not tell you, because the docs describe the endpoints rather than their warm-up behaviour.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live immediately after verification:&lt;/strong&gt; &lt;code&gt;GetUrlSubmissionQuota&lt;/code&gt; and &lt;code&gt;SubmitUrlBatch&lt;/code&gt;. Both worked on the first call, roughly a day after the verification file went up. You can build the entire submission pipeline before any reporting data exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not live for days:&lt;/strong&gt; the traffic endpoints. On 2026-08-21, &lt;code&gt;GetRankAndTrafficStats&lt;/code&gt; returned a single row — dated 2026-08-18, with 609 impressions and 3 clicks. One row, not a series. And the date on it is the verification date, which means Bing does not backfill history from before you verified. Whatever the site was doing in search the week before, that data does not arrive later; it was never yours to read.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;GetQueryStats&lt;/code&gt;, called in the same script run seconds later, returned zero rows. Three days after verification, with 609 impressions already recorded, the query breakdown was still empty. Impressions arrive first; the queries that produced them arrive later.&lt;/p&gt;

&lt;p&gt;The practical consequence is a code-shape decision. An empty array from these endpoints is not an error and it is not a zero — it is "not yet". Our snapshot script returns &lt;code&gt;{ days: 0, impressions: 0, clicks: 0, queries: 0 }&lt;/code&gt; when the array is empty and &lt;code&gt;null&lt;/code&gt; when the call throws, so a warm-up day and an outage day look different in the series. If you collapse both into &lt;code&gt;0&lt;/code&gt;, your first week of history is a fiction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we have not tested:&lt;/strong&gt; how long &lt;code&gt;GetQueryStats&lt;/code&gt; takes to populate, whether it needs a minimum impression threshold to return anything, and whether &lt;code&gt;GetCrawlStats&lt;/code&gt;, &lt;code&gt;GetPageStats&lt;/code&gt;, or &lt;code&gt;GetCrawlIssues&lt;/code&gt; behave the same way — we have not exercised those three at all. One site, one verification, one week. Treat the lag figures as a lower bound on what to expect, not a schedule.&lt;/p&gt;

&lt;p&gt;For context on why this endpoint is worth the trouble at all: on the same date, Google Search Console showed 37 indexed URLs against 633 "Crawled - currently not indexed", and Google offers no public API for requesting indexation — that stays manual clicking. Bing has both the submission API and the traffic API. It is the only search series we can record without a human opening a console.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three failure modes worth writing code around
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;An empty &lt;code&gt;urlList&lt;/code&gt; is an HTTP 400.&lt;/strong&gt; This is not an edge case — a daily job hits it the first morning after the quota is spent, and again every morning after the corpus is fully submitted. The guard is four lines: if the batch is empty, log and &lt;code&gt;exit 0&lt;/code&gt;. Without it, your cron mails you a failure every day for a job that is working correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors come back as HTTP 200.&lt;/strong&gt; &lt;code&gt;SubmitUrlBatch&lt;/code&gt; returns a 200 with &lt;code&gt;ErrorCode&lt;/code&gt; and &lt;code&gt;Message&lt;/code&gt; in the JSON body for at least some failures. Checking &lt;code&gt;response.ok&lt;/code&gt; is not sufficient; you have to parse the body and check &lt;code&gt;ErrorCode&lt;/code&gt; before treating the submission as done — otherwise you write those URLs into your state file as sent, and they never get retried.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything is wrapped in &lt;code&gt;d&lt;/code&gt;.&lt;/strong&gt; Responses follow the old WCF/ASMX JSON convention, so the quota lives at &lt;code&gt;j.d.DailyQuota&lt;/code&gt;, not &lt;code&gt;j.DailyQuota&lt;/code&gt;, and the traffic rows at &lt;code&gt;j.d&lt;/code&gt;. A missing &lt;code&gt;d&lt;/code&gt; on a 200 response means the call failed in a way the status code did not report. Our quota function throws on it explicitly.&lt;/p&gt;

&lt;p&gt;One non-API item that belongs in the same checklist: check your &lt;code&gt;robots.txt&lt;/code&gt; for &lt;code&gt;Crawl-Delay&lt;/code&gt;. Google ignores that directive; Bing honours it. We dropped ours on 2026-08-17. Submitting 100 URLs a day to a crawler you have separately instructed to slow down is self-defeating, and nothing in the API surfaces the conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would we pick this over IndexNow?
&lt;/h2&gt;

&lt;p&gt;Not as a replacement. We run both, and they do different jobs.&lt;/p&gt;

&lt;p&gt;IndexNow reaches the same crawler, has no daily quota, and needs no key rotation or per-site verification handshake. For normal publishing — a few URLs a day, announced as they change — IndexNow alone is enough, and the Webmaster API adds operational surface for nothing.&lt;/p&gt;

&lt;p&gt;The API earns its place in two situations. The first is a bulk change: after a prune took this corpus from 702 articles to 269, we needed to push the survivors rather than announce a diff, and 100/day with resumable state is the mechanism for that. The second is telemetry — IndexNow tells you nothing back, while &lt;code&gt;GetRankAndTrafficStats&lt;/code&gt; gives you an impressions series you can record weekly without a console.&lt;/p&gt;

&lt;p&gt;The condition that flips it: if you never do bulk corpus changes and already have search data from another source, skip the API and keep IndexNow.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/bing-webmaster-api-100-url-daily-quota-in-practice/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
    <item>
      <title>Astro middleware can't serve 410 Gone under output: 'static' — the catch-all route that can</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Wed, 19 Aug 2026 07:33:40 +0000</pubDate>
      <link>https://dev.to/pickuma/astro-middleware-cant-serve-410-gone-under-output-static-the-catch-all-route-that-can-5gge</link>
      <guid>https://dev.to/pickuma/astro-middleware-cant-serve-410-gone-under-output-static-the-catch-all-route-that-can-5gge</guid>
      <description>&lt;p&gt;On 2026-08-17 we deleted 434 articles from this site. Search Console was reporting 633 pages as "Crawled — currently not indexed" against 37 indexed URLs, and the fix was to stop asking Google to crawl interchangeable summary pages. Deleting the MDX files is the easy half. The other half is making every one of those URLs answer &lt;code&gt;410 Gone&lt;/code&gt; instead of &lt;code&gt;404 Not Found&lt;/code&gt;, so crawlers drop them and stop spending budget re-checking.&lt;/p&gt;

&lt;p&gt;The obvious place for that logic in Astro is &lt;code&gt;src/middleware.ts&lt;/code&gt;. It does not work. Under &lt;code&gt;output: 'static'&lt;/code&gt; the middleware runs during &lt;code&gt;astro build&lt;/code&gt;, and the URL you point curl at still comes back 404. This is what we shipped instead, on Astro 6.3.1, &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; 13.5.0, and wrangler 3.80.0, deployed as a Worker with Static Assets (&lt;code&gt;compatibility_date = "2026-05-01"&lt;/code&gt;, &lt;code&gt;nodejs_compat&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Two separate reasons middleware can't return a 410
&lt;/h2&gt;

&lt;p&gt;They compound, and they need different fixes, so it's worth separating them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build-time execution.&lt;/strong&gt; With &lt;code&gt;output: 'static'&lt;/code&gt;, every route is prerendered. Astro invokes middleware as part of that render pipeline while the build is running. Returning &lt;code&gt;new Response(null, { status: 410 })&lt;/code&gt; from &lt;code&gt;onRequest&lt;/code&gt; changes what the build writes to disk — it does not change what Cloudflare sends at request time, because at request time no JavaScript of yours is involved. Astro still emits &lt;code&gt;dist/server/virtual_astro_middleware.mjs&lt;/code&gt;, which is what makes this confusing: the file exists in the deploy artifact, so it looks wired up. It is only reachable if at least one route opts out of prerendering.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The asset router runs before your Worker.&lt;/strong&gt; Even once a server bundle exists, Cloudflare Workers Static Assets resolves the request against &lt;code&gt;dist/client&lt;/code&gt; first. Our &lt;code&gt;wrangler.toml&lt;/code&gt; has:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[assets]&lt;/span&gt;
&lt;span class="py"&gt;directory&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./dist"&lt;/span&gt;
&lt;span class="py"&gt;binding&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ASSETS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a file matches the path, the asset binding serves it and your Worker code never executes. You can invert that with &lt;code&gt;run_worker_first&lt;/code&gt;, but paying a Worker invocation on every hit of every live article to catch a fixed list of dead paths is the wrong trade. Leave the default and let the Worker handle only the misses — which is exactly the set you care about.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Both failure modes present identically: &lt;code&gt;curl -sI&lt;/code&gt; returns 404 and your middleware appears to do nothing. Before you debug the handler, check whether a request-time context exists at all. If &lt;code&gt;dist/server/&lt;/code&gt; contains no route your code can reach, the middleware never ran in production regardless of what it returns.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The routes that do run
&lt;/h2&gt;

&lt;p&gt;Adding one &lt;code&gt;prerender = false&lt;/code&gt; route is what creates the request-time path. &lt;code&gt;src/pages/[...gone].astro&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
export const prerender = false;

return isGone(Astro.url.pathname) ? goneResponse() : notFoundResponse(Astro.url);
---
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the pre-restructure &lt;code&gt;/posts/&amp;lt;slug&amp;gt;/&lt;/code&gt; shape. It does not cover &lt;code&gt;/for-dev/&amp;lt;slug&amp;gt;/&lt;/code&gt;, and the reason is the part we got wrong first.&lt;/p&gt;

&lt;p&gt;Our articles are served by &lt;code&gt;src/pages/for-[audience]/[...slug].astro&lt;/code&gt;, which is prerendered from the content collection via &lt;code&gt;getStaticPaths()&lt;/code&gt;. A rest route still &lt;em&gt;matches&lt;/em&gt; slugs that aren't in its static path list. So an unknown &lt;code&gt;/for-dev/&amp;lt;deleted-slug&amp;gt;/&lt;/code&gt; matched the prerendered rest route, resolved to nothing, and 404'd before the root catch-all was ever consulted. Route matching happens before your handler, so there is nothing to patch inside the handler.&lt;/p&gt;

&lt;p&gt;The fix is a sibling route, &lt;code&gt;src/pages/for-[audience]/[gone].astro&lt;/code&gt;, identical body, also &lt;code&gt;prerender = false&lt;/code&gt;. Astro ranks a single-segment dynamic parameter above a rest parameter, so &lt;code&gt;[gone]&lt;/code&gt; wins the match against &lt;code&gt;[...slug]&lt;/code&gt; — and because it opts out of prerendering, it executes per request. Live articles are unaffected: they're static files in &lt;code&gt;dist/client&lt;/code&gt; and the asset router serves them before SSR routing is consulted at all.&lt;/p&gt;

&lt;p&gt;The list itself is a plain &lt;code&gt;Set&lt;/code&gt; in &lt;code&gt;src/lib/gone.ts&lt;/code&gt;, carrying both URL shapes for each of the 434 removed articles, with the lookup normalising trailing slashes:&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;isGone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;GONE_PATHS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Normalise the slash. Cloudflare will hand you both forms and the Set only holds one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two smaller things that each cost a deploy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Astro.rewrite('/404')&lt;/code&gt; throws at runtime when the 404 page is prerendered — you cannot rewrite to a page that has no server handler. The not-found path fetches the built asset and re-wraps 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;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/404.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&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;text/html; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Separately, &lt;code&gt;@astrojs/cloudflare&lt;/code&gt; writes &lt;code&gt;dist/server/wrangler.json&lt;/code&gt; with &lt;code&gt;{"binding":"SESSION"}&lt;/code&gt; and no &lt;code&gt;id&lt;/code&gt;, which &lt;code&gt;wrangler deploy&lt;/code&gt; rejects outright. We patch the id back in with &lt;code&gt;scripts/post-build-patch.ts&lt;/code&gt; as a build step. Note that the adapter's generated config is what ships — its &lt;code&gt;assets.directory&lt;/code&gt; is &lt;code&gt;../client&lt;/code&gt;, not the &lt;code&gt;./dist&lt;/code&gt; in the repo-root &lt;code&gt;wrangler.toml&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a Cloudflare deploy behaves differently from your config, read &lt;code&gt;dist/server/wrangler.json&lt;/code&gt; rather than &lt;code&gt;wrangler.toml&lt;/code&gt;. The adapter rewrites paths and bindings, and the generated file is the one wrangler actually deploys.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What we can't tell you yet
&lt;/h2&gt;

&lt;p&gt;We deleted these URLs on 2026-08-17 and we're writing this on 2026-08-19. We cannot claim a crawl or ranking recovery, because two days is not enough time for one, and we will not dress up the deploy as a result. Google's documented position is that 404 and 410 are treated nearly identically, with 410 dropped somewhat faster. We picked 410 because re-crawl budget was the specific problem and 410 is the only status that says "do not come back." You cannot A/B this on a single site, so we won't pretend we measured it. We append a dated row to &lt;code&gt;docs/traffic-snapshots.json&lt;/code&gt; weekly; that series is the only thing that will settle it.&lt;/p&gt;

&lt;p&gt;We also did not test the edge-side alternative. Cloudflare Redirect Rules and Bulk Redirects can act on a URL list without it ever entering your deploy artifact. If your gone list is small, stable, and unrelated to your content pipeline, that is probably the better place for it — it survives framework changes and costs no Worker invocation. We kept ours in the Worker because the list is derived from the content collection and changes with it, and because a &lt;code&gt;Set&lt;/code&gt; of a few hundred strings is invisible next to the bundle.&lt;/p&gt;

&lt;p&gt;The condition that makes all of this unnecessary: if you run &lt;code&gt;output: 'server'&lt;/code&gt;, Astro middleware executes per request and a five-line &lt;code&gt;onRequest&lt;/code&gt; handles the whole problem. The trap is specific to prerendered sites where the middleware file exists, builds cleanly, and silently does nothing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/astro-static-410-gone-cloudflare-workers-catch-all-route/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
    <item>
      <title>Spotting Datacentre Traffic in RUM Data: 4,940 Views at 100% Direct and 0% Mobile</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Wed, 19 Aug 2026 07:32:23 +0000</pubDate>
      <link>https://dev.to/pickuma/spotting-datacentre-traffic-in-rum-data-4940-views-at-100-direct-and-0-mobile-nnm</link>
      <guid>https://dev.to/pickuma/spotting-datacentre-traffic-in-rum-data-4940-views-at-100-direct-and-0-mobile-nnm</guid>
      <description>&lt;p&gt;On 2026-08-18 our Cloudflare RUM panel listed Singapore as one of the site's largest traffic sources: 4,940 pageviews, 100% direct, 0% mobile. No human population produces that shape. Real readers arrive mixed — some from a link, some on a phone, some with a referer their browser happened to keep. A bucket that is exactly 100% direct &lt;em&gt;and&lt;/em&gt; exactly 0% mobile is a headless browser fleet or a scraper pool sitting in a datacentre region, and it had been padding our numbers for weeks before anyone looked at the columns side by side.&lt;/p&gt;

&lt;p&gt;We have no server logs to check it against. Astro static output on Cloudflare Workers with Static Assets means the edge serves the file and the application never sees a request line, an IP, or a user agent. What we do have is browser-side RUM, which only fires when JavaScript runs, and a &lt;code&gt;clicks&lt;/code&gt; table in Supabase written by the &lt;code&gt;/go/[slug]&lt;/code&gt; affiliate redirect. That is a thin instrument. It still works, because the three fields it does capture — country, referer, device class — disagree in a specific and repeatable way when the visitor is not a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal is the disagreement, not any single field
&lt;/h2&gt;

&lt;p&gt;Each field on its own is defensible. A 100% direct bucket could be dark social: newsletter clients, Slack, an in-app browser that strips the referer. A 0% mobile bucket could just be a developer audience on desktops. One country dominating could be a genuine regional hit — a local aggregator picked you up.&lt;/p&gt;

&lt;p&gt;What has no innocent reading is all three at once, at their extremes, in the same bucket. Dark social still produces mobile traffic. A desktop-heavy developer audience still produces &lt;em&gt;some&lt;/em&gt; referers. A regional hit still shows a device split. The combination is the tell, and it is visible in any analytics product that will break pageviews down by country, and cross it with referer type and device class.&lt;/p&gt;

&lt;p&gt;The thresholds we settled on, and these are judgement calls rather than anything derived from a labelled dataset:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The country accounts for more than roughly 5% of total pageviews&lt;/li&gt;
&lt;li&gt;Direct share within that country is above 95%&lt;/li&gt;
&lt;li&gt;Mobile share within that country is below 5%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Singapore cleared all three by a wide margin. It is also, not coincidentally, one of the densest cloud regions in Asia-Pacific — AWS, GCP, Azure, DigitalOcean and Vultr all have capacity there. Country in RUM is derived from IP geolocation, so a scraper running on a rented box in &lt;code&gt;ap-southeast-1&lt;/code&gt; reports as Singapore in exactly the way a reader in Singapore does. The country field is not measuring readership, it is measuring where the compute is.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once you exclude a country from pageviews, you have created two populations that are no longer comparable. If your pageview number excludes Singapore and China but your click number does not, any click-through rate you compute across them is wrong in your favour. Keep both counts named and separate — we track &lt;code&gt;qualified&lt;/code&gt; (all non-bot clicks) and &lt;code&gt;qualifiedClean&lt;/code&gt; (the same, minus the excluded regions) and never quote one where the other belongs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why our crawler flag missed about half of it
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;clicks&lt;/code&gt; table already had a &lt;code&gt;bot&lt;/code&gt; column. Its rule: mark the click as a bot if the request has no referer, or if the user agent matches a known crawler string. That is the standard cheap heuristic and it does catch a lot.&lt;/p&gt;

&lt;p&gt;It also has a blind spot that turned out to be large. On the 2026-08-18 window, 222 clicks passed the &lt;code&gt;bot&lt;/code&gt; filter as qualified. Excluding Singapore and China took that to 115. Roughly half of what the flag called human traffic came from two buckets whose country-referer-device signature said otherwise, with China the largest single bucket.&lt;/p&gt;

&lt;p&gt;The reason is structural. A crawler that reaches an affiliate link by &lt;em&gt;following an internal link from an article&lt;/em&gt; sends a referer — your own domain. A "has referer" test passes it cleanly. So the flag is weakest against exactly the automation that crawls your site properly, page by page, which is also the automation most likely to hit an outbound link. The naive scrapers that hit a URL cold get caught; the well-behaved ones that walk your navigation do not.&lt;/p&gt;

&lt;p&gt;That is why the country signature is worth computing even when you already have a UA-based flag. They fail on different populations, and the overlap between them is smaller than you would guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this cannot tell you
&lt;/h2&gt;

&lt;p&gt;This is a heuristic over three coarse fields, and it is wrong in known directions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot see non-JS traffic at all.&lt;/strong&gt; RUM fires from a script tag. A &lt;code&gt;curl&lt;/code&gt; loop, a Python &lt;code&gt;requests&lt;/code&gt; scraper, or anything that pulls HTML without a browser engine never appears in the dataset. Whatever bot share this method reports, your real share is higher — this measures only the subset of automation that bothers to execute JavaScript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It produces false positives you have to accept.&lt;/strong&gt; A reader on a datacentre-hosted VPN, behind a corporate proxy egress, or on a privacy browser that strips referers will look like a bot on two of the three axes. We accept that cost because the residual is small against 4,940 pageviews from a single country. At smaller volumes the ratio flips and the method stops being safe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;We did not test the things that would actually settle it.&lt;/strong&gt; No user-agent entropy analysis, no TLS fingerprinting (JA3/JA4), no ASN lookup on the source IP. Those give you a per-request answer instead of a per-bucket guess, and they are what you should reach for if you can.&lt;/p&gt;

&lt;p&gt;The alternative we would pick given the option: put a Worker in front of the origin and read Cloudflare's bot score and the client ASN per request, or ship request logs to somewhere queryable. ASN plus bot score beats country heuristics on every axis — it is per-request, it does not confuse a Singaporean reader with a Singaporean EC2 instance, and it survives someone routing their scrapers through residential proxies far better. The condition that flips it back to the heuristic: you are on a fully static host with no request-level logging, no paid analytics tier, and no budget for either. Then three fields is what you have.&lt;/p&gt;

&lt;p&gt;The other hard constraint is time. Cloudflare RUM keeps a rolling window and Search Console shows no history at all. If you do not append a dated row somewhere durable each week, the series has a hole in it that can never be filled. We write ours to a committed JSON file in the repo; the point is that it is dated, append-only and outside the tool that expires it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Before you exclude a country permanently, check whether the signature holds across two or three separate weeks. A one-off spike with a clean device mix is more likely a real referral you have not identified yet. The 100%/0% pattern is stable week over week when it is infrastructure.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/datacentre-traffic-rum-country-referer-device-signature/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>IndexNow Batch Mode: The lastmod Diff That Took 286 URLs Per Publish Down to 0</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Wed, 19 Aug 2026 07:31:06 +0000</pubDate>
      <link>https://dev.to/pickuma/indexnow-batch-mode-the-lastmod-diff-that-took-286-urls-per-publish-down-to-0-5832</link>
      <guid>https://dev.to/pickuma/indexnow-batch-mode-the-lastmod-diff-that-took-286-urls-per-publish-down-to-0-5832</guid>
      <description>&lt;p&gt;Bing Webmaster Tools put a banner on our IndexNow page: &lt;strong&gt;"IndexNow is in batch mode."&lt;/strong&gt; The recommendation underneath was to stream instead — send URLs as they change rather than announcing the whole site at once. We were announcing 286 URLs on every publish, several times a week, because our submit script read &lt;code&gt;sitemap-0.xml&lt;/code&gt; and POSTed every &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; in it. Two new articles, 286 URLs.&lt;/p&gt;

&lt;p&gt;The fix is about 40 lines, and it is not the part of IndexNow the protocol docs spend time on. The docs cover the key file, the POST body shape, and the 10,000-URL cap per request. They do not cover deciding &lt;em&gt;which&lt;/em&gt; URLs belong in the request, which is the entire problem the moment your sitemap is larger than your publish.&lt;/p&gt;

&lt;h2&gt;
  
  
  What batch mode is actually measuring
&lt;/h2&gt;

&lt;p&gt;IndexNow has no per-day quota to blow through. The endpoint accepts up to 10,000 URLs in one &lt;code&gt;urlList&lt;/code&gt; and returns a 2xx either way. Nothing rejects a full-sitemap submission — you get a warning in a dashboard, and the stated cost is load on the engine plus slower handling of the changes you actually care about.&lt;/p&gt;

&lt;p&gt;That framing decides what you do about it. This is not an error you can detect from the API response. Our script logged &lt;code&gt;200 OK&lt;/code&gt; on every one of those full-sitemap runs, for months. The only signal lived in a dashboard nobody opens daily.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A success response from &lt;code&gt;api.indexnow.org&lt;/code&gt; means your payload parsed and your key file resolved. It says nothing about whether the engine will act on the URLs, and nothing about whether you are submitting sensibly. If you wired up IndexNow once and moved on, open the IndexNow page in Bing Webmaster Tools before assuming it is working.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The diff: lastmod is the state you already have
&lt;/h2&gt;

&lt;p&gt;The precondition comes first, because it decides whether any of this works: &lt;strong&gt;your sitemap's &lt;code&gt;lastmod&lt;/code&gt; has to reflect content changes, not build times.&lt;/strong&gt; A sitemap integration will happily derive &lt;code&gt;lastmod&lt;/code&gt; from file mtime, and our article generator rewrites post files on every run whether the prose changed or not. So &lt;code&gt;astro.config.mjs&lt;/code&gt; reads each post's &lt;code&gt;updatedAt&lt;/code&gt; frontmatter at config-load time and serializes that as &lt;code&gt;lastmod&lt;/code&gt; instead. If &lt;code&gt;lastmod&lt;/code&gt; is effectively &lt;code&gt;new Date()&lt;/code&gt; at build, every URL differs from stored state on every build, the diff skips nothing, and you have written a slower version of the same batch submission.&lt;/p&gt;

&lt;p&gt;Given a &lt;code&gt;lastmod&lt;/code&gt; you can trust, the change is bookkeeping. Parse per-&lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt; blocks rather than bare &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt; tags, so location and timestamp stay paired:&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;parseSitemapEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&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;block&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;url&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;url&amp;gt;/g&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;loc&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;loc&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;loc&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="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;lastmod&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;lastmod&amp;gt;/&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;out&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;state&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;loadState&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;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastmod&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;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;lastmod&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;loc&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;loc&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State goes to a gitignored &lt;code&gt;scripts/.indexnow-submitted.json&lt;/code&gt;. The first run after the change announced 286 URLs and wrote the file. The second run, with nothing published in between, printed &lt;code&gt;Streaming mode: 0 changed, 286 unchanged (skipped)&lt;/code&gt; and sent no request at all. An &lt;code&gt;--all&lt;/code&gt; flag forces the full list back for recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways this quietly does nothing
&lt;/h2&gt;

&lt;p&gt;Both of these are live in our own script. Neither shows up on the happy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;29 of our 286 URLs carry no &lt;code&gt;lastmod&lt;/code&gt; at all.&lt;/strong&gt; The built sitemap has 286 &lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt; blocks and 257 &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt; elements. The 29 without are the homepage, &lt;code&gt;/about/&lt;/code&gt;, and the category and tag listings — routes the sitemap integration emits with &lt;code&gt;changefreq&lt;/code&gt; and &lt;code&gt;priority&lt;/code&gt; only, because the lastmod map is keyed on post frontmatter and these are not posts. They store as an empty string in state, so the filter compares &lt;code&gt;'' !== ''&lt;/code&gt;, gets &lt;code&gt;false&lt;/code&gt;, and skips them permanently. The homepage changes on every single publish, since it lists the newest articles, and it now gets announced exactly once ever. The listing pages are the ones most worth streaming and they are precisely the ones the diff drops. The fix is to give those routes a real &lt;code&gt;lastmod&lt;/code&gt; — the max of the posts they contain — not to special-case the empty string.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;State is written before the POST is confirmed.&lt;/strong&gt; Our script records &lt;code&gt;entries&lt;/code&gt; to the state file and &lt;em&gt;then&lt;/em&gt; calls &lt;code&gt;submit()&lt;/code&gt;, which logs a non-2xx and moves on; the whole thing exits 0 by design so a syndication hiccup cannot break a deploy. Put those two properties together and a 403 from a rotated key marks all 286 URLs as announced while announcing none of them. The next run diffs clean and sends nothing. Recovery is one &lt;code&gt;--all&lt;/code&gt; run, but you have to notice first, and nothing tells you.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a script swallows errors on purpose, keep every durable state write behind the success check. Ours does not yet — that is the next change, and it is a two-line move, not a redesign.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The channels, and when to skip all of this
&lt;/h2&gt;

&lt;p&gt;Three separate mechanisms get conflated. They are not interchangeable:&lt;/p&gt;

&lt;p&gt;
  headers={['Channel', 'Auth', 'Ceiling', 'Best shape']}&lt;br&gt;
  rows={[&lt;br&gt;
    ['IndexNow (Bing, Yandex, Seznam)', 'Key file at /.txt, no account', '10,000 URLs per request', 'Diff on publish'],&lt;br&gt;
    ['Bing URL Submission API', 'API key from Webmaster Tools', '100/day, 1,300/month on our site', 'Daily cron draining a backlog'],&lt;br&gt;
    ['Google', 'No public request-indexing API', 'Manual clicks in Search Console', 'Sitemap and patience'],&lt;br&gt;
  ]}&lt;br&gt;
/&amp;gt;&lt;/p&gt;

&lt;p&gt;A sitemap ping tells an engine to re-read a file it already polls. IndexNow names specific URLs. Streaming logic only applies to the second — there is nothing to diff about the first.&lt;/p&gt;

&lt;p&gt;If you would rather not own any of this, a hosted CMS maintains sitemap timestamps and search-engine pings for you, and the whole problem disappears along with the 40 lines.&lt;/p&gt;

&lt;p&gt;The condition that flips it: if you publish from a repo and want &lt;code&gt;lastmod&lt;/code&gt; bound to a frontmatter field you control rather than to a save event in an editor, hand-rolling wins, and 40 lines is the entire cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What we did not test:&lt;/strong&gt; whether streaming changed indexing outcomes. Two days is not a result. Every claim here is bounded to submission behaviour — 286 down to 0 on an unchanged run, verified from the script's own output — not to crawl rate or index coverage. Search Console showed 633 pages crawled-and-not-indexed against 37 indexed on 2026-08-17, and if that number moves, IndexNow batching will be one of a dozen changes made in the same window. We will not be able to attribute it, and neither should you.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/indexnow-batch-mode-lastmod-diff/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
    <item>
      <title>Workers KV's 60-Second Consistency Window: What It Actually Costs You</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Mon, 17 Aug 2026 02:30:09 +0000</pubDate>
      <link>https://dev.to/pickuma/workers-kvs-60-second-consistency-window-what-it-actually-costs-you-4i6b</link>
      <guid>https://dev.to/pickuma/workers-kvs-60-second-consistency-window-what-it-actually-costs-you-4i6b</guid>
      <description>&lt;p&gt;Cloudflare's Workers KV documentation gives you a number: a write may take up to 60 seconds to become visible in other locations. That number usually gets filed away as a worst-case replication delay and forgotten. It is more useful to read it as a cache TTL you are not permitted to lower — the &lt;code&gt;cacheTtl&lt;/code&gt; option on &lt;code&gt;get()&lt;/code&gt; defaults to 60 seconds and has a documented minimum of 60 seconds — and the cost it imposes on your application depends on something the docs do not put front and centre: how recently the key you just wrote was read &lt;em&gt;in the location doing the reading&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Three published limits frame everything below.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Propagation of a write to other locations: documented as up to 60 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;get()&lt;/code&gt; with &lt;code&gt;cacheTtl&lt;/code&gt;: default 60 seconds, minimum 60 seconds. There is no zero.&lt;/li&gt;
&lt;li&gt;Writes to a single key: roughly one per second. KV is not a counter, and it is not a lock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We did not run a global propagation benchmark for this article. A table of PoP-by-PoP timings measured from one machine on one afternoon would look like evidence and be worth very little — the number you actually care about is a distribution that moves with routing and cache occupancy. What follows is about the shape of the failure, which is stable, rather than the milliseconds, which are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60 seconds is a cache TTL, not a replication delay
&lt;/h2&gt;

&lt;p&gt;There are two mechanisms sitting between your &lt;code&gt;put()&lt;/code&gt; and someone else's &lt;code&gt;get()&lt;/code&gt;, and they fail differently.&lt;/p&gt;

&lt;p&gt;The first is propagation from KV's central store outward. The second is a read cache in front of it, local to the location serving the request. When a key is requested in a location that holds no cached copy, the read falls through toward the central store, and you often observe the new value well inside the 60-second window. When a key &lt;em&gt;does&lt;/em&gt; have a cached copy in that location, you get the cached copy until its TTL expires, no matter how quickly the underlying propagation finished.&lt;/p&gt;

&lt;p&gt;That inverts the intuition most caches train into you. Here, staleness scales with popularity. A key nobody reads is close to fresh on first access. A key read a thousand times a minute in Frankfurt is pinned to whatever Frankfurt last fetched. The keys carrying the highest staleness risk are exactly the ones you reached for KV to hold: feature flags, routing tables, config blobs, session lookups.&lt;/p&gt;

&lt;p&gt;Two consequences follow directly.&lt;/p&gt;

&lt;p&gt;Your staging environment lies to you. Low traffic means cold caches, which means reads-after-write that look fast and correct. The behaviour that bites you only appears under the read volume that keeps the cache warm, and that is production.&lt;/p&gt;

&lt;p&gt;Per-key TTL also means per-key expiry. If a logical change spans two keys, they expire independently. A reader in one location can see the new value of key A and the old value of key B for tens of seconds. KV offers no cross-key atomicity and no transactions, and nothing in the API will warn you that you just wrote a change that cannot land atomically.&lt;/p&gt;

&lt;p&gt;Treat the 60 as a design guideline from the docs rather than a guarantee. It is not an SLA figure, and under incident conditions the real window is unbounded — Cloudflare's published post-mortem for the 12 June 2025 outage describes a multi-hour KV disruption that propagated into other Cloudflare products built on KV. A design whose correctness rests on "it will be there in a minute" has no defined behaviour for the day it is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure that reads like data loss: a cached null
&lt;/h2&gt;

&lt;p&gt;The pattern that generates support tickets is a uniqueness check written the obvious way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taken&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;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KV&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="s2"&gt;`slug:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;taken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;already taken&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;409&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;KV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`slug:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This has two defects, and the second is the expensive one.&lt;/p&gt;

&lt;p&gt;The first is the familiar race: two concurrent requests both read &lt;code&gt;null&lt;/code&gt;, both write, the later write wins, and the loser is never told. No error surfaces anywhere. You find it weeks later in a support thread.&lt;/p&gt;

&lt;p&gt;The second is that a miss is cacheable. Cloudflare's KV documentation describes negative lookups as cached the same way hits are — the answer "this key does not exist" is itself an entry with a TTL. So the single-user, zero-concurrency path breaks too:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The availability check reads &lt;code&gt;slug:acme&lt;/code&gt;, gets &lt;code&gt;null&lt;/code&gt;, and that &lt;code&gt;null&lt;/code&gt; is now cached in the location serving that user.&lt;/li&gt;
&lt;li&gt;The write succeeds.&lt;/li&gt;
&lt;li&gt;The confirmation page — same user, same location, seconds later — reads &lt;code&gt;slug:acme&lt;/code&gt;, hits the cached &lt;code&gt;null&lt;/code&gt;, and renders a not-found state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user watched the form succeed and then watched their thing fail to exist. That reads as data loss, and it self-heals in about a minute, which makes it close to impossible to reproduce on demand. Worth verifying negative-cache behaviour against the current docs before you build around it: the KV caching layer was rearchitected in 2024 and the details are not frozen.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reading a key to check whether it is absent is the most costly thing you can do to a key you are about to write. If you must probe for absence, probe a store with read-after-write semantics — a Durable Object, or D1 with a &lt;code&gt;UNIQUE&lt;/code&gt; constraint — and let KV serve only the reads that tolerate a stale answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two fixes, in order of how much they buy you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not re-read what you just wrote.&lt;/strong&gt; After &lt;code&gt;put()&lt;/code&gt;, return the value you already hold in memory. This sounds too obvious to write down until you notice how many frameworks POST, redirect, and then re-fetch — at which point the read is a fresh request that knows nothing about the write and goes straight to the local cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make keys immutable and version the pointer.&lt;/strong&gt; Write the payload under a content-addressed or versioned key such as &lt;code&gt;config:v41&lt;/code&gt;, never overwritten, then update one small pointer key. A stale read then returns a &lt;em&gt;coherent older version&lt;/em&gt; rather than a mixture. The window does not disappear; the failure mode changes from inconsistent to behind, and behind is something you can reason about, display, and alert on.&lt;/p&gt;

&lt;p&gt;If that migration means touching every KV call site in a codebase, it is grep-able, mechanical work — the kind worth handing to an agent under one clear rule (no &lt;code&gt;get()&lt;/code&gt; on a key this request writes) rather than doing by hand across forty files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we would reach for instead, and the condition that flips it
&lt;/h2&gt;

&lt;p&gt;KV stays the right default when reads vastly outnumber writes, values are whole documents fetched by key, and a stale answer costs nothing worse than a slightly old page. Published paid-plan rates are $0.50 per million reads and $5.00 per million writes, deletes and lists, with a free tier of 100,000 reads and 1,000 writes per day. Read-heavy workloads are cheap here in a way strongly consistent stores are not, and that is the actual reason to accept the window.&lt;/p&gt;

&lt;p&gt;The condition that flips it is narrow and absolute: correctness depends on a read reflecting your own write, or two keys must change together. Then use a Durable Object. One object per entity gives you single-threaded, strongly consistent access, and the price is a network hop to that object's home region — a read KV would serve locally in single-digit milliseconds can become a cross-continent round trip. For a global read path that is a real regression, which is why the usual answer is both: a Durable Object or D1 as the system of record, KV as the read-optimised projection in front of it, and a version pointer so you can measure how far behind the projection is.&lt;/p&gt;

&lt;p&gt;If you cannot currently say which of your KV keys are read-after-write critical, that inventory is the work to do before the next incident rather than after it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/workers-kv-eventual-consistency-window/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
    <item>
      <title>Schema Validation Is Not Enough: When Agent Output Passes Zod and Still Breaks the Build</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:19:53 +0000</pubDate>
      <link>https://dev.to/pickuma/schema-validation-is-not-enough-when-agent-output-passes-zod-and-still-breaks-the-build-897</link>
      <guid>https://dev.to/pickuma/schema-validation-is-not-enough-when-agent-output-passes-zod-and-still-breaks-the-build-897</guid>
      <description>&lt;p&gt;An agent that writes files into your repo is a compiler with no type checker on its output. Zod gives you one — but it checks the &lt;em&gt;shape&lt;/em&gt; of what the model returned. Your build checks what that shape &lt;em&gt;means&lt;/em&gt;. Those are different jobs, and everything that lives in the gap between them is where scheduled agents fail at 3am with nobody watching.&lt;/p&gt;

&lt;p&gt;We run a nightly pipeline that drafts articles, generates summary blocks, writes MDX to disk, builds a static site, and deploys it. Every artifact that reaches the build step has already passed a &lt;code&gt;z.parse()&lt;/code&gt;. Over roughly three months of nightly runs, the build still broke about a dozen times. None of those failures were type errors. Every one was a value that was structurally perfect and semantically wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Passing zod is a claim about shape, not about the world
&lt;/h2&gt;

&lt;p&gt;Here is a schema close to what we started with:&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;Article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z0-9-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="mi"&gt;20&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="mi"&gt;120&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ai-dev-tools&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;infrastructure&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;meta&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="na"&gt;publishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coerce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&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="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a reasonable schema. It also accepts, without a single complaint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;slug&lt;/code&gt; that is already a file on disk, so the write silently replaces an article published two weeks earlier.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;tools&lt;/code&gt; entry naming a product that has no record in the affiliate table, so the footer component renders an empty card and the page ships with a dead link.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;publishedAt&lt;/code&gt; three days in the future, which is a valid &lt;code&gt;Date&lt;/code&gt; and an invisible article.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;body&lt;/code&gt; containing the characters &lt;code&gt;{config}&lt;/code&gt; inside a sentence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is a green parse and a red build — or worse, a green build and a broken page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;z.string().url()&lt;/code&gt; is the most misleading validator in the library. It asserts that a string parses as a URL and nothing more. A link to a page that 404s, a domain you never registered, and a redirect for an affiliate program that got paused last week all pass identically. If the URL has to &lt;em&gt;exist&lt;/em&gt;, the check is a network call — and a network call does not belong inside a schema.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Four failure classes that survive a clean parse
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Referential drift.&lt;/strong&gt; The output contains an identifier that must resolve to something else: a slug, a category, a tool id, an image path, a foreign key. Zod confirms it is a string matching a pattern. Nothing confirms the target exists. Our worst instance was a category value that passed &lt;code&gt;z.enum()&lt;/code&gt; — the enum was correct, the category page generated, and it generated with zero posts in it, because the enum listed a category we had drained months earlier. A published empty page is a ranking liability that no parser will ever flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Cross-field contradiction.&lt;/strong&gt; Each field is individually fine and the combination is nonsense. &lt;code&gt;updatedAt&lt;/code&gt; earlier than &lt;code&gt;publishedAt&lt;/code&gt;. An &lt;code&gt;audience&lt;/code&gt; field of &lt;code&gt;pm&lt;/code&gt; on a file the writer put in the &lt;code&gt;dev&lt;/code&gt; directory. A &lt;code&gt;readTimeMinutes&lt;/code&gt; of 4 on a 3,000-word body. Zod can catch these — but only if you reach for &lt;code&gt;superRefine&lt;/code&gt;, and most schemas are written field-by-field, which is exactly the frame in which cross-field bugs are invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Collisions and re-runs.&lt;/strong&gt; A scheduled agent is not a one-shot script. It gets killed mid-run, retried, and run again the next night on overlapping inputs. The same topic produces the same slug twice. Both outputs validate. The second overwrites the first, and your git diff shows a content change rather than an error. Validation has no concept of what already exists; it only sees the object in front of it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Valid string, invalid artifact.&lt;/strong&gt; This is the one that actually broke our build, twice. A model wrote the phrase &lt;em&gt;pass &lt;code&gt;{config}&lt;/code&gt; to the runner&lt;/em&gt; into prose. MDX treats &lt;code&gt;{...}&lt;/code&gt; as a JavaScript expression, so the compiler tried to resolve an identifier named &lt;code&gt;config&lt;/code&gt; and failed the entire build — not just the page. &lt;code&gt;z.string()&lt;/code&gt; saw 41 perfectly ordinary characters. Same class of bug: a bare &lt;code&gt;&amp;lt;Tool&amp;gt;&lt;/code&gt; in a sentence, a code fence the model opened and never closed, a component referenced in the body but missing from the import block.&lt;/p&gt;

&lt;p&gt;The pattern behind all four: your schema validates against the &lt;em&gt;type&lt;/em&gt;, and your failures happen at the &lt;em&gt;consumer&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate against the consumer, not the type
&lt;/h2&gt;

&lt;p&gt;We now run three layers, in order, all before the artifact is written to its final path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — shape.&lt;/strong&gt; Keep your zod schema exactly as it is. It is fast, it is pure, and it catches genuinely malformed output. Just stop treating it as the gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — resolution.&lt;/strong&gt; Every identifier in the output gets looked up against the thing it points at. Tool slugs query the affiliate table. Category values are checked against categories that currently have posts. The target file path is checked for an existing file, and a collision is a hard failure rather than an overwrite. This layer needs I/O, which is precisely why it does not belong inside the schema — schemas should stay synchronous and pure so you can unit-test them without a database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — dry render.&lt;/strong&gt; Compile the artifact the way the build compiles it, on the string, before it ever touches disk:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mdx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&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;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mdx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;jsx&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;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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&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="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is nine lines, and it moved our MDX parse failures from build-time to generation-time. The difference matters more than it sounds: at generation-time the agent is still running, still holds the context, and can retry with the compiler's own error message pasted into the prompt. At build-time it is a broken file, a red deploy, and a human reading a stack trace the next morning.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When layer 2 or 3 fails, do not crash the run and do not silently drop the artifact. Write it to a &lt;code&gt;quarantine/&lt;/code&gt; directory with the failure message in a sibling file, and exit non-zero from the &lt;em&gt;generation&lt;/em&gt; step only. Two properties fall out of this: the run stays idempotent, so a retry cannot half-publish, and the failure is inspectable the next morning instead of being a log line that scrolled away.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The rule we ended up with: put the check where the failure actually happens. If a value breaks the renderer, test it with the renderer. If it breaks because a row is missing, query the row. A schema tells you the model returned an object of the right shape. It has never told you the object was correct.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/schema-validation-is-not-enough-agent-output-breaks-build/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Cache Races in a Publish Pipeline: Why Your IndexNow Ping Misses the Pages You Just Shipped</title>
      <dc:creator>pickuma</dc:creator>
      <pubDate>Thu, 13 Aug 2026 06:18:37 +0000</pubDate>
      <link>https://dev.to/pickuma/cache-races-in-a-publish-pipeline-why-your-indexnow-ping-misses-the-pages-you-just-shipped-4ldi</link>
      <guid>https://dev.to/pickuma/cache-races-in-a-publish-pipeline-why-your-indexnow-ping-misses-the-pages-you-just-shipped-4ldi</guid>
      <description>&lt;p&gt;We publish from a scheduled agent: build, deploy, ping IndexNow, cross-post. For months the ping step returned HTTP 200 on every run and we filed that under done. Then we diffed the list of URLs we had submitted against what crawlers actually fetched, and found a batch of pages that had been pinged, fetched within the minute, and served either the pre-deploy version or a 404.&lt;/p&gt;

&lt;p&gt;Nothing errored. Nothing retried. The pipeline reported success on every one of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ping is fast; your edge is not
&lt;/h2&gt;

&lt;p&gt;IndexNow inverts the crawl. Instead of waiting for a bot to rediscover your sitemap on its own schedule, you push a list of URLs and participating engines fetch them soon after. In our logs the first crawler hit typically lands within a minute or two of the ping. That speed is the entire value proposition, and it is also the bug.&lt;/p&gt;

&lt;p&gt;The naive pipeline is three steps in one process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Build.&lt;/li&gt;
&lt;li&gt;Deploy — the API returns success.&lt;/li&gt;
&lt;li&gt;Ping IndexNow with the new URLs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 2 returning success means the control plane accepted your artifact. It does not mean every edge node is serving the new HTML, and it does not mean cached responses for those paths were invalidated. So the timeline becomes: deploy returns at t+0, ping fires at t+0, crawler fetches at t+40s from a POP that has not caught up yet.&lt;/p&gt;

&lt;p&gt;Negative caching is the sharp edge here. A path that did not exist yesterday can already have a cached miss at some edge — from a preview link you opened, a broken internal link, a scanner probing paths. Your deploy adds the page at origin. That edge keeps answering from its stored 404 until the TTL expires. The crawler asks exactly once, quickly, and it asks the edge.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The IndexNow response tells you nothing about the outcome. A 200 or 202 means "list accepted, key valid." It is not an acknowledgement that any URL was fetched, and certainly not that it was fetched successfully. If that status code is your only success signal, every race described here is invisible to your pipeline.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why the checks you would reach for first prove nothing
&lt;/h2&gt;

&lt;p&gt;Three verification habits that feel rigorous and are not:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Curling the URL with a cache buster.&lt;/strong&gt; Fetching &lt;code&gt;https://yoursite/for-dev/slug/?v=123&lt;/code&gt; returns your new HTML. That proves origin has the page. It also created a different cache key from the one you submitted. The canonical URL can still be serving stale while your check passes. We shipped two "verified" deploys this way before noticing the query string was doing the lying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Checking the sitemap.&lt;/strong&gt; Sitemaps usually carry a longer TTL than HTML pages. A crawler can read a stale sitemap alongside a fresh page, or the reverse. Sitemap freshness and page freshness are separate races; confirming one says nothing about the other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trusting the deploy tool's completion message.&lt;/strong&gt; Whatever the host — object storage behind a CDN, a worker, a container — the deploy call returns when the artifact is accepted, and propagation is asynchronous. On our worker deploys, the gap between "deploy returned" and "every edge we could sample served new content" ranged from a couple of seconds to over a minute. The tail is where pings die, and the tail is exactly what an unbounded async operation does not report.&lt;/p&gt;

&lt;p&gt;The check that holds is narrower than any of those: fetch the canonical URL, no query string, with &lt;code&gt;Cache-Control: no-cache&lt;/code&gt; on the request, and confirm you got a 200 &lt;strong&gt;and&lt;/strong&gt; a marker unique to this build. We inject the build's short git SHA into a meta tag on every page; the poll passes only when the fetched HTML contains the SHA the current run produced. A bare status check will happily pass on a stale-but-valid previous version of an updated article, which is the failure mode you are least likely to notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sequence that survives a cold edge
&lt;/h2&gt;

&lt;p&gt;Ordering matters more than any individual check. What we run now:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build&lt;/strong&gt;, emitting the git SHA into every page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Purge explicitly&lt;/strong&gt; — the exact new URLs, plus &lt;code&gt;sitemap.xml&lt;/code&gt;, plus any machine-readable indexes like &lt;code&gt;llms.txt&lt;/code&gt; or &lt;code&gt;articles.json&lt;/code&gt;. Purging is what kills a cached negative response. Polling alone just waits out its TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll each submitted URL individually&lt;/strong&gt; until it returns 200 with the current SHA. Cap it: we allow roughly 90 seconds per URL at a 3-second interval. URLs that fail get dropped from the submission list rather than pinged anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ping IndexNow&lt;/strong&gt; with only the URLs that passed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record what was submitted&lt;/strong&gt;, with a timestamp and the SHA at submit time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 6 is the one people skip and the one that converts belief into evidence. Without a submission log, a URL that was never pinged is indistinguishable from a URL that was pinged into a 404. We keep a small JSON file keyed by URL; a follow-up job checks days later whether those URLs show up in coverage reports and re-submits the ones that do not.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If a run publishes several pages, poll them concurrently but ping once. IndexNow accepts a URL list in a single request. A per-URL ping loop multiplies rate-limit exposure for no benefit, and a partial failure halfway through leaves you without a clean record of what was actually submitted.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two smaller things that cost us runs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key file is a single point of failure.&lt;/strong&gt; The verification key file at your domain root gets fetched by the engine to confirm ownership. If it sits behind the same CDN and any build ships without it, a cached 404 there invalidates the entire batch — not one URL, all of them. Treat it as a static asset with a long TTL and never let it be conditionally generated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not submit URLs that redirect.&lt;/strong&gt; We moved older posts from a flat path to audience-prefixed paths. Submitting the old URL wastes the slot: the engine follows the 301, but the canonical you wanted indexed was the target all along. Submit whatever your URL helper produces for the current build, not the path the previous build used.&lt;/p&gt;

&lt;p&gt;The same race applies well beyond search crawlers, and the blast radius elsewhere is worse. When a social post or a cross-post platform unfurls your link, it fetches your OG tags once and caches the result for a long time — sometimes indefinitely, with manual re-scrape as the only fix. A crawler that gets a stale page will come back. A link preview that gets a 404 keeps showing a broken card until you go clear it by hand. Gate the announcement fan-out on the same verification, not just the ping.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://pickuma.com/for-dev/indexnow-cache-race-publish-pipeline/?utm_source=devto&amp;amp;utm_medium=crosspost&amp;amp;utm_campaign=blog" rel="noopener noreferrer"&gt;pickuma.com&lt;/a&gt;. Subscribe to &lt;a href="https://pickuma.com/rss.xml" rel="noopener noreferrer"&gt;the RSS&lt;/a&gt; or follow &lt;a href="https://bsky.app/profile/pickuma.bsky.social" rel="noopener noreferrer"&gt;@pickuma.bsky.social&lt;/a&gt; for new reviews.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>cloud</category>
      <category>astro</category>
    </item>
  </channel>
</rss>
