<?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: ushiro</title>
    <description>The latest articles on DEV Community by ushiro (@ai_changewatch).</description>
    <link>https://dev.to/ai_changewatch</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%2F4069435%2Faafa7bc0-db03-4090-9c0c-df5b2ca91a0f.png</url>
      <title>DEV Community: ushiro</title>
      <link>https://dev.to/ai_changewatch</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ai_changewatch"/>
    <language>en</language>
    <item>
      <title>A `middleware.ts` Rewrite Silently Disables ISR in Next.js 15.5</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 01 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/a-middlewarets-rewrite-silently-disables-isr-in-nextjs-155-2d37</link>
      <guid>https://dev.to/ai_changewatch/a-middlewarets-rewrite-silently-disables-isr-in-nextjs-155-2d37</guid>
      <description>&lt;p&gt;Every page on my site declared &lt;code&gt;export const revalidate = 300&lt;/code&gt;. Nine locales served from ISR.&lt;br&gt;
The tenth — the one that is actually canonical, the one crawlers hit most — re-rendered from scratch&lt;br&gt;
on every single request for weeks.&lt;/p&gt;

&lt;p&gt;The difference between them was not a page, a config flag, or a deployment. It was that the tenth&lt;br&gt;
locale's URL went through a &lt;code&gt;NextResponse.rewrite()&lt;/code&gt; in &lt;code&gt;middleware.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://aichangewatch.com" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Change Watch&lt;/strong&gt;&lt;/a&gt;, a Next.js App Router site on Cloudflare&lt;br&gt;
Workers (via OpenNext) that crawls what AI vendors publish about their own models and records every&lt;br&gt;
change. &lt;code&gt;en&lt;/code&gt; is served unprefixed (&lt;code&gt;/deprecations&lt;/code&gt;), the other nine locales are prefixed&lt;br&gt;
(&lt;code&gt;/ja/deprecations&lt;/code&gt;). That unprefixed mapping was one line of middleware.&lt;/p&gt;
&lt;h2&gt;
  
  
  What the headers said
&lt;/h2&gt;

&lt;p&gt;Measured on production, 2026-08-06. Same page, same component tree, same &lt;code&gt;revalidate = 300&lt;/code&gt; — only&lt;br&gt;
the routing path differs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/bot, /rankings                (rewritten in middleware)
  Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate
  (no x-nextjs-* headers at all)

/ja/bot, /ja/rankings          (passed through with next())
  Cache-Control: s-maxage=300, stale-while-revalidate=...
  x-nextjs-prerender: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;private, no-store&lt;/code&gt; on a page whose whole point is to be cached. And not "cached badly" — there are&lt;br&gt;
no &lt;code&gt;x-nextjs-*&lt;/code&gt; headers on those responses whatsoever, which means Next never treated the request as&lt;br&gt;
a route that has an incremental cache entry. Nothing was written, so nothing could ever be read.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this hides so well
&lt;/h2&gt;

&lt;p&gt;Three things kept this invisible for weeks, and I think each one is general.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prefixed locales are fine.&lt;/strong&gt; Any "is my ISR working?" check you run against &lt;code&gt;/ja/...&lt;/code&gt; passes.&lt;br&gt;
The bug is per-path, and it only touches the paths middleware rewrote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Probing the prefixed form of the broken URL measures nothing.&lt;/strong&gt; &lt;code&gt;/en/bot&lt;/code&gt; 307-redirects to the&lt;br&gt;
canonical &lt;code&gt;/bot&lt;/code&gt; (verified again today), so &lt;code&gt;curl -I /en/bot&lt;/code&gt; returns a redirect and tells you&lt;br&gt;
nothing about the page. You have to test the &lt;strong&gt;unprefixed&lt;/strong&gt; URL. I burned an eight-minute polling&lt;br&gt;
loop on &lt;code&gt;/en/bot&lt;/code&gt; before noticing that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pages still refresh, so content never looks stale.&lt;/strong&gt; Deploys change the buildId, the buildId is part&lt;br&gt;
of the cache key space, and this repo deploys several times a day — so the whole cache was being&lt;br&gt;
invalidated often enough that no page was ever visibly out of date. &lt;code&gt;revalidate = 300&lt;/code&gt; was decorative,&lt;br&gt;
and deployment frequency was covering for it. (There was a second, independent reason revalidation&lt;br&gt;
never ran on this stack; that one is&lt;br&gt;
&lt;a href="https://dev.to/ai_changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn"&gt;its own post&lt;/a&gt;.)&lt;/p&gt;
&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;This is &lt;a href="https://github.com/vercel/next.js/issues/83862" rel="noopener noreferrer"&gt;vercel/next.js#83862&lt;/a&gt; — &lt;em&gt;"SWR Cache-Control&lt;br&gt;
disabled after Next.js 15.5 when using a rewrite middleware"&lt;/em&gt;, open, filed 2025-09-16, reported&lt;br&gt;
against 15.4.2-canary.2 through 15.5.3. I reproduce it on 15.5.21.&lt;/p&gt;

&lt;p&gt;The explanation in the issue is that Next matches the &lt;strong&gt;pre-rewrite&lt;/strong&gt; path against the&lt;br&gt;
dynamic-route regexes. &lt;code&gt;/bot&lt;/code&gt; matches nothing (the real route is &lt;code&gt;app/[locale]/[provider]/page.tsx&lt;/code&gt;),&lt;br&gt;
so the response falls back to the &lt;code&gt;private, no-store&lt;/code&gt; default. That mechanism is upstream's account&lt;br&gt;
and the internals are not something I can observe from outside; what I can state is the header pair&lt;br&gt;
above, and that it flips based solely on whether the rewrite happened in middleware.&lt;/p&gt;

&lt;p&gt;The important consequence is that &lt;strong&gt;it is a write-side failure, not a read-side one.&lt;/strong&gt; No entry is&lt;br&gt;
ever created for those keys. So nothing that improves cache &lt;em&gt;lookup&lt;/em&gt; can help.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two things that did not fix it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Rewriting the rewrite.&lt;/strong&gt; There is no shape of &lt;code&gt;NextResponse.rewrite()&lt;/code&gt; that avoids this. It is not&lt;br&gt;
a matcher problem or an ordering problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Serving ISR from the adapter's routing layer&lt;/strong&gt; (OpenNext's &lt;code&gt;enableCacheInterception: true&lt;/code&gt;) looked&lt;br&gt;
like the perfect workaround: resolve the cache before NextServer is ever invoked, and the pre-rewrite&lt;br&gt;
path matching stops mattering. Cache hits did work — I have &lt;code&gt;x-opennext-cache: HIT&lt;/code&gt; on prefixed&lt;br&gt;
locales to prove it. It still could not fix English, because the cache was empty for those keys, and&lt;br&gt;
then it took the site down:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error in routingHandler
  at Object.send (worker.js:116290)     at computeCacheControl (worker.js:120329)
  at generateResult (worker.js:120394)  at cacheInterceptor (worker.js:121493)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a &lt;strong&gt;stale&lt;/strong&gt; entry the interceptor has to dispatch the background re-render itself, and at that&lt;br&gt;
point I had no revalidation queue bound. Inside NextServer that same failure is caught and logged as&lt;br&gt;
a warning. In the routing layer it wasn't caught, so the request returned 500. Two properties made it&lt;br&gt;
much worse than an ordinary bug: it threw &lt;em&gt;before&lt;/em&gt; the render, so the entry could never refresh and&lt;br&gt;
the 500 was permanent per URL; and it only fired once an entry passed its &lt;code&gt;revalidate&lt;/code&gt; window, so&lt;br&gt;
pages died &lt;strong&gt;one at a time over several hours&lt;/strong&gt; — 12 URLs before I reverted it.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post, take that shape: a failure that is caught in one layer and&lt;br&gt;
uncaught in the layer you moved it to.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Move the rewrite out of middleware and into &lt;code&gt;next.config.mjs&lt;/code&gt;. A config rewrite lands in the routes&lt;br&gt;
manifest, which the adapter's routing layer applies to the internal request, so NextServer receives a&lt;br&gt;
plain &lt;code&gt;/en/...&lt;/code&gt; request with no &lt;code&gt;x-middleware-rewrite&lt;/code&gt; header — the exact path &lt;code&gt;/ja/...&lt;/code&gt; always took.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;rewrites&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;reserved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en|ja|zh|es|de|fr|ko|pt|it|tr|api|_next|sitemaps&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;afterFiles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/en&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;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/:seg((?!(?:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;reserved&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="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/en/:seg&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;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/:seg((?!(?:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;reserved&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)(?:/|$))[^/]+)/:rest*`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/en/:seg/:rest*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Middleware keeps the &lt;code&gt;/en/... → /...&lt;/code&gt; &lt;strong&gt;redirect&lt;/strong&gt;. Redirects are unaffected; only rewrites are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;afterFiles&lt;/code&gt;, not &lt;code&gt;beforeFiles&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;afterFiles&lt;/code&gt; runs only when no real route matched, so&lt;br&gt;
&lt;code&gt;/sitemap.xml&lt;/code&gt;, &lt;code&gt;/robots.txt&lt;/code&gt;, &lt;code&gt;/icon.svg&lt;/code&gt; and friends resolve as themselves before this pattern is&lt;br&gt;
consulted, and drop out of the exclusion list for free. With &lt;code&gt;beforeFiles&lt;/code&gt; every one of them needs an&lt;br&gt;
explicit exclusion, and each missing exclusion is a 404 on a canonical URL.&lt;/p&gt;
&lt;h2&gt;
  
  
  The exclusion regex has two traps
&lt;/h2&gt;

&lt;p&gt;Both of these produce a config that builds fine and 404s in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 1: anchor each alternative to a segment boundary.&lt;/strong&gt; The negative lookahead has to end with&lt;br&gt;
&lt;code&gt;(?:/|$)&lt;/code&gt;, not &lt;code&gt;$&lt;/code&gt;. With &lt;code&gt;$&lt;/code&gt; alone it only fires when the reserved word ends the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                 $ only                    (?:/|$)
/bot          -&amp;gt; /en/bot                 -&amp;gt; /en/bot
/ja/bot       -&amp;gt; /en/ja/bot   ← 404      -&amp;gt; (no rewrite)  ✓
/en/bot       -&amp;gt; /en/en/bot   ← 404      -&amp;gt; (no rewrite)  ✓
/api/contact  -&amp;gt; /en/api/contact ← 404   -&amp;gt; (no rewrite)  ✓
/sitemaps/1   -&amp;gt; /en/sitemaps/1 ← 404    -&amp;gt; (no rewrite)  ✓
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That table is &lt;code&gt;RegExp.exec&lt;/code&gt; output, not a sketch. It is also the same family of bug as writing bare&lt;br&gt;
&lt;code&gt;api&lt;/code&gt; in a matcher, which swallows &lt;code&gt;/api-features&lt;/code&gt; — a page of mine that 404'd on its canonical URL&lt;br&gt;
for exactly that reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trap 2: root-level &lt;em&gt;dynamic&lt;/em&gt; routes are not protected.&lt;/strong&gt; The "real routes win first" property of&lt;br&gt;
&lt;code&gt;afterFiles&lt;/code&gt; is gated on the adapter's &lt;strong&gt;static&lt;/strong&gt; route matcher. &lt;code&gt;/sitemaps/[id]&lt;/code&gt; is root-level and&lt;br&gt;
dynamic, so it is not covered, and it has to be named in &lt;code&gt;reserved&lt;/code&gt; by hand. If you add a root-level&lt;br&gt;
dynamic route later, it needs the same entry — there is nothing to remind you.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to check it, in the order that actually works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Test the compiled regex, not the source string.&lt;/strong&gt; What runs is the pattern Next compiles into&lt;br&gt;
&lt;code&gt;.next/routes-manifest.json&lt;/code&gt;, and it is not what you typed. Build to a scratch directory&lt;br&gt;
(&lt;code&gt;NEXT_DIST_DIR=.next-rwtest next build&lt;/code&gt;), read the manifest, and assert every URL class you care&lt;br&gt;
about — locale-prefixed, &lt;code&gt;/en/&lt;/code&gt;-prefixed, &lt;code&gt;/api/*&lt;/code&gt;, &lt;code&gt;/_next/*&lt;/code&gt;, root-level files, root-level dynamic&lt;br&gt;
routes, the feeds. All four shadowing bugs above were caught this way before deploying, and none of&lt;br&gt;
them was visible in the source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Then check the unprefixed URL on production.&lt;/strong&gt; Today, on the same pages, 2026-08-27:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://aichangewatch.com/bot | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'cache\|nextjs'&lt;/span&gt;
Cache-Control: s-maxage&lt;span class="o"&gt;=&lt;/span&gt;3600, stale-while-revalidate&lt;span class="o"&gt;=&lt;/span&gt;31532400
x-nextjs-cache: STALE
x-nextjs-prerender: 1
x-nextjs-stale-time: 300

&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://aichangewatch.com/deprecations | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'nextjs'&lt;/span&gt;
x-nextjs-cache: HIT
x-nextjs-prerender: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;x-nextjs-prerender: 1&lt;/code&gt; is the header that was &lt;strong&gt;absent&lt;/strong&gt; before, and it is the one to look for.&lt;br&gt;
&lt;code&gt;x-nextjs-cache: MISS&lt;/code&gt; on its own proves nothing — that is the earlier post's subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I still haven't proven
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whether this reproduces off this adapter.&lt;/strong&gt; Every measurement here is Next 15.5.21 +
&lt;code&gt;@opennextjs/cloudflare&lt;/code&gt; on Cloudflare Workers. The upstream issue is not adapter-specific and the
reporters were not on my stack, but I have not tested Vercel or a plain &lt;code&gt;next start&lt;/code&gt; myself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why the &lt;code&gt;no-store&lt;/code&gt; fallback is chosen.&lt;/strong&gt; I am quoting the issue's explanation of the path
matching, not something I read out of the runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 500 attribution.&lt;/strong&gt; That the interceptor threw at &lt;code&gt;Object.send&lt;/code&gt; before the render is read off
the stack trace and the fact that the 500s stopped on revert. I did not instrument it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you serve a default locale unprefixed on App Router, the check is one command and the failing case&lt;br&gt;
looks completely healthy: full-SSR responses are correct, just uncached. Test the URL your users get,&lt;br&gt;
not the internal one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The site this came out of is &lt;a href="https://aichangewatch.com" rel="noopener noreferrer"&gt;AI Change Watch&lt;/a&gt; — vendor deprecation&lt;br&gt;
tables, pricing and SDK changelogs, diffed on a schedule. The pages in the measurements above are&lt;br&gt;
real ones; &lt;code&gt;/deprecations&lt;/code&gt; is the one with the highest cache-hit value, which is why it was the first&lt;br&gt;
thing I noticed serving &lt;code&gt;no-store&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>performance</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>The iCalendar Spec Says 75 Octets, Not 75 Characters</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Thu, 27 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/the-icalendar-spec-says-75-octets-not-75-characters-5cn8</link>
      <guid>https://dev.to/ai_changewatch/the-icalendar-spec-says-75-octets-not-75-characters-5cn8</guid>
      <description>&lt;p&gt;I run &lt;a href="https://aichangewatch.com" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Change Watch&lt;/strong&gt;&lt;/a&gt;, a small independent project that&lt;br&gt;
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing&lt;br&gt;
and SDK releases — and records every time one of them changes.&lt;/p&gt;

&lt;p&gt;One of the things it publishes is a subscribable calendar: every announced model shutdown, as &lt;code&gt;.ics&lt;/code&gt;,&lt;br&gt;
so a date the vendor moves updates in your calendar instead of in a changelog you forgot to read.&lt;/p&gt;

&lt;p&gt;The English feed worked immediately. The Japanese one was rejected.&lt;/p&gt;

&lt;p&gt;Same code. Same events. The only difference was the language of the text inside.&lt;/p&gt;
&lt;h2&gt;
  
  
  The line that RFC 5545 actually specifies
&lt;/h2&gt;

&lt;p&gt;Section 3.1:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lines of text SHOULD NOT be longer than 75 &lt;strong&gt;octets&lt;/strong&gt;, excluding the line break.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Not 75 characters. &lt;strong&gt;75 octets.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For ASCII those are the same number, which is exactly why this survives every test you are likely to&lt;br&gt;
write. &lt;code&gt;SUMMARY:gpt-4-32k shutdown (OpenAI)&lt;/code&gt; is 36 characters and 36 bytes. Nothing to notice.&lt;/p&gt;

&lt;p&gt;Then the same field comes back translated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SUMMARY:code-davinci-001 提供終了（OpenAI）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 30 characters. It is &lt;strong&gt;49 octets&lt;/strong&gt; — Japanese runs about 3 bytes per character in UTF-8. A&lt;br&gt;
folder that counts characters looks at 30, decides no fold is needed, and emits a line that is legal by&lt;br&gt;
its own arithmetic and illegal by the spec's.&lt;/p&gt;

&lt;p&gt;Longer titles cross 75 octets while still well under 75 characters, and that is the line the parser&lt;br&gt;
rejects.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why counting characters isn't the only bug
&lt;/h2&gt;

&lt;p&gt;The obvious fix — count bytes instead — introduces a second one if you write it 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="c1"&gt;// Still broken.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;75&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&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;Slicing a UTF-8 buffer at a fixed offset &lt;strong&gt;cuts through the middle of a character&lt;/strong&gt;. Byte 75 lands in&lt;br&gt;
the second of the three bytes that make up 終, and you emit half a codepoint on one line and the other&lt;br&gt;
half on the next. Some parsers replace it with U+FFFD, some abort the file.&lt;/p&gt;

&lt;p&gt;So the fold has to be counted in bytes but &lt;em&gt;taken&lt;/em&gt; at character boundaries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;foldLine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&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;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&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;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;75&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;line&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;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cur&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="nx"&gt;curBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// The first line gets 75 octets; every continuation gets 74, because the leading&lt;/span&gt;
  &lt;span class="c1"&gt;// space that marks it as a continuation counts toward the limit too.&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;75&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;ch&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;          &lt;span class="c1"&gt;// iterating a string yields whole codepoints&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;curBytes&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;cur&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="nx"&gt;curBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;74&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;curBytes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&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="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;l&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;l&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\r\n&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;Three things in there are easy to leave out and each one produces a file that mostly works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;for (const ch of line)&lt;/code&gt;, not &lt;code&gt;line[i]&lt;/code&gt;.&lt;/strong&gt; Indexing a JS string walks UTF-16 code units, so an emoji&lt;br&gt;
or any astral-plane character gets split at the surrogate pair. Iterating with &lt;code&gt;for...of&lt;/code&gt; yields whole&lt;br&gt;
codepoints.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The limit drops from 75 to 74 after the first line.&lt;/strong&gt; The continuation marker is a single leading&lt;br&gt;
space and it counts. Keep the limit at 75 and every folded line is one octet over — which is the same&lt;br&gt;
bug you just fixed, only harder to see because it only shows up on lines long enough to fold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;\r\n&lt;/code&gt;, not &lt;code&gt;\n&lt;/code&gt;.&lt;/strong&gt; RFC 5545 wants CRLF. Plenty of parsers tolerate bare LF, right up until one&lt;br&gt;
doesn't.&lt;/p&gt;
&lt;h2&gt;
  
  
  The other one: your UID is not a display string
&lt;/h2&gt;

&lt;p&gt;An &lt;code&gt;.ics&lt;/code&gt; feed served in ten languages raises a question the spec answers but does not warn you about:&lt;br&gt;
what is the &lt;code&gt;UID&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;If you build it from the localized summary, the same shutdown gets a different UID in every language.&lt;br&gt;
Subscribe to two of them and your calendar shows &lt;strong&gt;two entries for one event&lt;/strong&gt;, forever, with no way to&lt;br&gt;
tell they are the same thing.&lt;/p&gt;

&lt;p&gt;So the UID has to be keyed on the &lt;em&gt;entity&lt;/em&gt;, never the presentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;UID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;shutdown-o1-preview@aichangewatch.com&lt;/span&gt;
&lt;span class="py"&gt;UID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;shutdown-davinci-002@aichangewatch.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Model slug, no locale. I checked the two live feeds while writing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en feed:  262 UIDs
ja feed:  262 UIDs
shared:   262            ← identical sets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which means a person subscribed to both gets one entry per shutdown, not two. The second subscription&lt;br&gt;
&lt;em&gt;overwrites&lt;/em&gt; the first rather than duplicating it. That is the correct failure mode — a collision beats&lt;br&gt;
a double-booking, because a duplicate calendar entry is something the user has to notice and clean up by&lt;br&gt;
hand.&lt;/p&gt;

&lt;p&gt;It also means you cannot use the UID to carry the language. If you need that, it belongs in the calendar&lt;br&gt;
name (&lt;code&gt;X-WR-CALNAME&lt;/code&gt;), not the identity.&lt;/p&gt;
&lt;h2&gt;
  
  
  What I check now
&lt;/h2&gt;

&lt;p&gt;The whole class is "a spec said octets and I read characters", and it is worth one assertion in a test&lt;br&gt;
rather than a careful reading:&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;renderCalendar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events&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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\r\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;75&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;Run it against the &lt;strong&gt;localized&lt;/strong&gt; feed, not the English one. The English feed cannot fail this test,&lt;br&gt;
which is precisely why it is not the one to run it on. Mine currently reports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;en feed:  6,545 lines,   0 over 75 octets,  506 continuation lines
ja feed:  6,606 lines,   0 over 75 octets,  567 continuation lines
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 61 extra lines in the Japanese feed are the folds the English one does not need. That gap &lt;em&gt;is&lt;/em&gt; the&lt;br&gt;
bug, made visible: same events, same code, more lines — because the same sentences take more bytes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The tracker this came out of is at &lt;a href="https://aichangewatch.com?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com&lt;/a&gt; — the&lt;br&gt;
shutdown calendar it generates is at&lt;br&gt;
&lt;a href="https://aichangewatch.com/deprecations/calendar.ics?src=devto" rel="noopener noreferrer"&gt;/deprecations/calendar.ics&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>i18n</category>
      <category>programming</category>
    </item>
    <item>
      <title>`next dev` Renders but Nothing Works: Your CSP Is Missing `unsafe-eval`</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Mon, 24 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/next-dev-renders-but-nothing-works-your-csp-is-missing-unsafe-eval-26pl</link>
      <guid>https://dev.to/ai_changewatch/next-dev-renders-but-nothing-works-your-csp-is-missing-unsafe-eval-26pl</guid>
      <description>&lt;p&gt;I run &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Change Watch&lt;/strong&gt;&lt;/a&gt;, a small independent project that&lt;br&gt;
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing&lt;br&gt;
and SDK releases — and records every time one of them changes.&lt;/p&gt;

&lt;p&gt;At some point I added a Content-Security-Policy. It was correct. It shipped. Production was fine.&lt;/p&gt;

&lt;p&gt;And then, locally, every interactive thing on the site stopped working.&lt;/p&gt;
&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;next dev&lt;/code&gt; starts. The page loads. It looks &lt;strong&gt;exactly right&lt;/strong&gt; — the layout, the data, the styles, all&lt;br&gt;
of it. Then:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the search box accepts text and filters nothing&lt;/li&gt;
&lt;li&gt;the sort headers don't sort&lt;/li&gt;
&lt;li&gt;the "show more" button does nothing&lt;/li&gt;
&lt;li&gt;no &lt;code&gt;onClick&lt;/code&gt; anywhere fires&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No error page. No red overlay. No failed request in the Network tab. The server rendered the HTML and&lt;br&gt;
sent it, so the page you are looking at is real — it is just &lt;strong&gt;completely inert&lt;/strong&gt;. Nothing hydrated.&lt;/p&gt;

&lt;p&gt;If you have not hit this before, the natural first guess is your own component. That is where I went,&lt;br&gt;
and it is the wrong place, because every component is fine.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one line that names it
&lt;/h2&gt;

&lt;p&gt;The console has it, but you have to be looking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an
allowed source of script in the following Content Security Policy directive:
"script-src 'self' 'unsafe-inline' …"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the reason it is easy to miss is that it is not a JavaScript error. It does not have a stack. It&lt;br&gt;
does not point at your file. It appears once, near the top, above whatever else the page logged, and it&lt;br&gt;
names a directive rather than a component.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this happens in dev and not in prod
&lt;/h2&gt;

&lt;p&gt;Next's development server compiles modules and hands them to the browser wrapped in &lt;code&gt;eval&lt;/code&gt; — that is&lt;br&gt;
how the dev &lt;code&gt;devtool&lt;/code&gt; setting works, and it is what React Refresh needs to swap a component without&lt;br&gt;
reloading the page. Fast Refresh is built on it.&lt;/p&gt;

&lt;p&gt;A production build does not do that. &lt;code&gt;next build&lt;/code&gt; emits static chunks. There is no string being&lt;br&gt;
evaluated at runtime, so there is nothing for &lt;code&gt;'unsafe-eval'&lt;/code&gt; to permit.&lt;/p&gt;

&lt;p&gt;Which produces the trap:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The CSP is correct for production and fatal in development — and development is where you spend all&lt;br&gt;
your time.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You will not catch it in CI, because CI builds. You will not catch it in preview, because preview&lt;br&gt;
builds. You catch it the moment you try to click something locally, and by then you are three commits&lt;br&gt;
into a feature and looking for the bug in your own diff.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the header reaches dev at all
&lt;/h2&gt;

&lt;p&gt;Because it is in &lt;code&gt;next.config&lt;/code&gt;, and that file has no idea which mode it is running in unless you tell&lt;br&gt;
it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/:path*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;securityHeaders&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;source: '/:path*'&lt;/code&gt; means every path. There is no dev/prod branch, so &lt;code&gt;next dev&lt;/code&gt; serves the same header&lt;br&gt;
&lt;code&gt;next start&lt;/code&gt; does. That is a reasonable default — you generally &lt;em&gt;want&lt;/em&gt; to develop against the headers&lt;br&gt;
you ship — it just happens to be wrong for this one directive.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two fixes, and they are not equivalent
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Option A — widen the policy in development only.&lt;/strong&gt;&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;isDev&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&lt;/span&gt;&lt;span class="dl"&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;csp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default-src 'self'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// 'unsafe-eval' is DEV-ONLY: the dev server evaluates compiled modules as strings (that is what&lt;/span&gt;
  &lt;span class="c1"&gt;// React Refresh is built on), and a production build never does. Shipping it would be a real&lt;/span&gt;
  &lt;span class="c1"&gt;// widening of the policy for zero benefit.&lt;/span&gt;
  &lt;span class="s2"&gt;`script-src 'self' 'unsafe-inline'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;isDev&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; 'unsafe-eval'&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="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// The HMR socket, same reasoning. In production nothing connects back to the dev server.&lt;/span&gt;
  &lt;span class="s2"&gt;`connect-src 'self'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;isDev&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; ws: wss:&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="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// …the rest&lt;/span&gt;
&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment is not decoration. A conditional in a security header is exactly the kind of line that gets&lt;br&gt;
"cleaned up" six months later by someone who reads it as an inconsistency, so the reason it is&lt;br&gt;
conditional has to sit next to it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — stop testing client behaviour in &lt;code&gt;next dev&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;next build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; next start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what I actually do, for a reason that has nothing to do with CSP: this project deploys to&lt;br&gt;
Cloudflare Workers through OpenNext, and &lt;code&gt;next dev&lt;/code&gt; is not the runtime it ships on. Behaviour I verify&lt;br&gt;
in dev is behaviour I verified somewhere the code will never run. So for anything client-side I build&lt;br&gt;
and serve the real thing.&lt;/p&gt;

&lt;p&gt;The cost is real — you lose Fast Refresh, and a rebuild per change is slow enough to change how you&lt;br&gt;
work. If your production runtime &lt;em&gt;is&lt;/em&gt; Node, Option A is the better trade. If it isn't, Option B was&lt;br&gt;
going to be necessary anyway and this just makes it obvious sooner.&lt;/p&gt;

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

&lt;p&gt;The thing worth taking away isn't the directive. It's this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A security header set in &lt;code&gt;next.config&lt;/code&gt; applies to the dev server, and the dev server has different&lt;br&gt;
requirements than the thing you deploy.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;unsafe-eval&lt;/code&gt; is the one that produces a &lt;em&gt;silent&lt;/em&gt; failure, which is why it costs the most time. But the&lt;br&gt;
same category catches you elsewhere:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;directive&lt;/th&gt;
&lt;th&gt;what dev needs that prod doesn't&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;script-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'unsafe-eval'&lt;/code&gt; for the module runtime / React Refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;connect-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ws:&lt;/code&gt; / &lt;code&gt;wss:&lt;/code&gt; for the HMR socket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;style-src&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;'unsafe-inline'&lt;/code&gt; if your prod build extracts CSS but dev injects it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you are about to add a CSP to a Next app, the fastest check is not a code review. It is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;next dev&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;open the page&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;click something&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;read the console — the first line, not the last&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Thirty seconds, and it is the only test that distinguishes "rendered" from "working". Everything else&lt;br&gt;
about a dead page looks identical to a live one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The tracker this came out of is at &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com&lt;/a&gt; — it watches AI&lt;br&gt;
vendor docs for changes. Its CSP still has no &lt;code&gt;'unsafe-eval'&lt;/code&gt; in production, which is the point.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>security</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>Your Worker Returned 500 and the Log Says `outcome: "ok"`</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Fri, 21 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/your-worker-returned-500-and-the-log-says-outcome-ok-2dla</link>
      <guid>https://dev.to/ai_changewatch/your-worker-returned-500-and-the-log-says-outcome-ok-2dla</guid>
      <description>&lt;p&gt;I run &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Change Watch&lt;/strong&gt;&lt;/a&gt;, a small independent project that&lt;br&gt;
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing&lt;br&gt;
and SDK releases — and records every time one of them changes.&lt;/p&gt;

&lt;p&gt;It runs on Cloudflare Workers, which means that when someone tells me "your site 500'd an hour ago",&lt;br&gt;
the obvious tool is useless. &lt;code&gt;wrangler tail&lt;/code&gt; is a &lt;strong&gt;live stream&lt;/strong&gt;. It shows you what is happening now.&lt;br&gt;
It cannot show you an hour ago.&lt;/p&gt;

&lt;p&gt;There is a way to read the past, and there are four traps in it that cost me most of a day.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that works
&lt;/h2&gt;

&lt;p&gt;Workers can write their invocation logs to a queryable store, and a REST endpoint reads it back. First&lt;br&gt;
the worker has to be opted in — this is the whole config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrangler.jsonc&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"observability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then you can ask for events in a time range:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sX&lt;/span&gt; POST &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT&lt;/span&gt;&lt;span class="s2"&gt;/workers/observability/telemetry/query"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$CF_API_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "queryId": "anything",
    "timeframe": { "from": 1786000000000, "to": 1786003600000 },
    "limit": 500,
    "view": "events",
    "parameters": {
      "datasets": ["cloudflare-workers"],
      "filters": [
        { "id": "f1", "key": "$workers.event.response.status",
          "type": "number", "operation": "eq", "value": 500 }
      ]
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;from&lt;/code&gt; and &lt;code&gt;to&lt;/code&gt; are &lt;strong&gt;epoch milliseconds&lt;/strong&gt;, not ISO strings. A read-scoped API token is enough — the&lt;br&gt;
one I already had for deploys worked unchanged.&lt;/p&gt;

&lt;p&gt;Each event carries more than you would guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$workers.outcome                              ok | exceededCpu | canceled
$workers.cpuTimeMs  /  $workers.wallTimeMs
$workers.event.request.path  /  .search
$workers.event.request.headers['user-agent']
$workers.event.request.cf.asOrganization      the ASN owner
$workers.event.response.status
$metadata.error
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the tool. Now the traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 1: a rendered 500 is a successful invocation
&lt;/h2&gt;

&lt;p&gt;I started by filtering on the field that sounds right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$workers.outcome"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eq"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exceededCpu"&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;and found nothing, repeatedly, while the site was demonstrably returning 500s.&lt;/p&gt;

&lt;p&gt;Because for every one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;"ok"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker ran. It produced a response. It returned it. That the response was an error page is not the&lt;br&gt;
runtime's problem — the invocation succeeded. &lt;strong&gt;&lt;code&gt;outcome&lt;/code&gt; describes the worker, not the HTTP result.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;outcome&lt;/code&gt; is the wrong axis for application errors. Filter on &lt;code&gt;$workers.event.response.status&lt;/code&gt; for&lt;br&gt;
what the user saw, and read &lt;code&gt;$metadata.error&lt;/code&gt; for the throw. &lt;code&gt;outcome&lt;/code&gt; is for failures the runtime&lt;br&gt;
itself noticed: CPU limit, cancellation.&lt;/p&gt;

&lt;p&gt;This is worth internalising because it inverts the usual relationship. In most stacks "the request&lt;br&gt;
failed" and "the handler failed" are the same event. At the edge they are two different fields — and&lt;br&gt;
the one with the friendlier name is the one that will not tell you.&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 2: a wide time window silently undercounts
&lt;/h2&gt;

&lt;p&gt;This is the one that actually cost me the day.&lt;/p&gt;

&lt;p&gt;I asked for 5xx across a 24-hour window, got nine events, and concluded I was chasing a single bug. The&lt;br&gt;
same 24 hours, walked in 4-hour slices and concatenated, returned &lt;strong&gt;956&lt;/strong&gt; — across 92 URLs and three&lt;br&gt;
unrelated causes.&lt;/p&gt;

&lt;p&gt;I re-ran the comparison today, on 404s, to check it was not a one-off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;24h asked as one query      →  26 events
same 24h in 4h slices       → 266 events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The single query returned 10% of what was there.&lt;/strong&gt; Not a rounding difference — a different&lt;br&gt;
conclusion. And nothing in the response says so: no truncation flag, no "results were sampled" field.&lt;br&gt;
You get a well-formed answer that happens to be mostly missing.&lt;/p&gt;

&lt;p&gt;So the loop, not the query:&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;out&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;let&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="nx"&gt;_000&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;to&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="nx"&gt;_000&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;ev&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;queryEvents&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`slice &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;h hit the limit — narrow it`&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ev.length &amp;gt;= 500&lt;/code&gt; check matters as much as the slicing. A slice that returns exactly your limit is&lt;br&gt;
truncated, and you have to narrow &lt;em&gt;that&lt;/em&gt; slice further. Without the warning you cannot tell "500 events&lt;br&gt;
happened" from "500 events fit".&lt;/p&gt;
&lt;h2&gt;
  
  
  Trap 3: &lt;code&gt;exists&lt;/code&gt; matches empty strings, and &lt;code&gt;includes&lt;/code&gt; ignores case
&lt;/h2&gt;

&lt;p&gt;Two smaller ones, both of which produced confidently wrong numbers before I noticed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;operation: "exists"&lt;/code&gt; matches a key that is present but empty.&lt;/strong&gt; I wanted requests Cloudflare had&lt;br&gt;
identified as verified bots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"$workers.event.request.cf.verifiedBotCategory"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"operation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"exists"&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;That field is present on every request and is &lt;code&gt;""&lt;/code&gt; on almost all of them, so the filter matched the&lt;br&gt;
entire dataset and I briefly believed the whole site was bot traffic. Use &lt;code&gt;exists&lt;/code&gt; only for keys&lt;br&gt;
genuinely absent on what you are excluding — &lt;code&gt;sec-fetch-mode&lt;/code&gt; is a real example, since non-browsers do&lt;br&gt;
not send it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;operation: "includes"&lt;/code&gt; is case-insensitive.&lt;/strong&gt; Filtering user agents for &lt;code&gt;bot&lt;/code&gt; and for &lt;code&gt;Bot&lt;/code&gt; returned&lt;br&gt;
the identical 3,181 events. Convenient once you know; misleading if you were using case to separate two&lt;br&gt;
populations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 4: event counts are not invocation counts
&lt;/h2&gt;

&lt;p&gt;The events view and the dashboard's invocation count disagree, and both are right. On one day my web&lt;br&gt;
worker showed &lt;strong&gt;24,085 telemetry events against 8,772 invocations&lt;/strong&gt; — roughly 2.7 events per&lt;br&gt;
invocation.&lt;/p&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;proportions&lt;/strong&gt; — "what share of requests were 404s", "which UA dominates" — take from the events view&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;absolute totals&lt;/strong&gt; — "how many requests did this worker serve" — take from
&lt;code&gt;workersInvocationsAdaptive&lt;/code&gt; in the GraphQL analytics API, not from counting events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Mixing them gives a number that is wrong by a factor you cannot see.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually run now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Past-tense debugging: "what 500'd between 3am and 4am".&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;queryEvents&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filters&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;r&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="s2"&gt;`https://api.cloudflare.com/client/v4/accounts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ACCOUNT&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/workers/observability/telemetry/query`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TOKEN&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="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;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;queryId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;q&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeframe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;view&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;events&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;datasets&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;cloudflare-workers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;filters&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;d&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;events&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…called from the slicing loop above, with the results grouped in plain JavaScript rather than by asking&lt;br&gt;
the API to group them. (&lt;code&gt;view: "calculations"&lt;/code&gt; with a &lt;code&gt;groupBy&lt;/code&gt; on a high-cardinality key returns only a&lt;br&gt;
few groups, quietly — the same failure mode as trap 2: a well-formed answer that is mostly missing.)&lt;/p&gt;

&lt;p&gt;The retention window is limited. I have reliably queried three days back and would not build a workflow&lt;br&gt;
that assumes more; for anything you need to keep, pull it out and store it yourself.&lt;/p&gt;

&lt;p&gt;One more, learned the embarrassing way: &lt;strong&gt;&lt;code&gt;cf.asOrganization&lt;/code&gt; is the ASN owner, not the bot.&lt;/strong&gt; Requests&lt;br&gt;
from "Anthropic, PBC" turned out to be a crawler that robots.txt already allowed, and "Amazon&lt;br&gt;
Technologies" was PerplexityBot. Identify declared crawlers by user agent; use the ASN only to catch&lt;br&gt;
traffic whose user agent is lying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;wrangler tail&lt;/code&gt; is for watching. For asking, use the telemetry API — and remember that &lt;strong&gt;&lt;code&gt;outcome: "ok"&lt;/code&gt;&lt;br&gt;
means the worker succeeded, not that your user did&lt;/strong&gt;, and that a query covering a wide window will hand&lt;br&gt;
you a confident answer built from a tenth of the data.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The tracker this came out of is at &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com&lt;/a&gt; — it watches AI&lt;br&gt;
vendor docs for changes, and the 500s that started all this were a REST detail endpoint quietly falling&lt;br&gt;
through to its collection endpoint.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>serverless</category>
      <category>webdev</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A Missing ID Doesn't 404 — It Fetches the Whole Collection</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 18 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/a-missing-id-doesnt-404-it-fetches-the-whole-collection-3ii2</link>
      <guid>https://dev.to/ai_changewatch/a-missing-id-doesnt-404-it-fetches-the-whole-collection-3ii2</guid>
      <description>&lt;p&gt;I run &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;&lt;strong&gt;AI Change Watch&lt;/strong&gt;&lt;/a&gt;, a small independent project that&lt;br&gt;
crawls what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing&lt;br&gt;
and SDK releases — and records every time one of them changes.&lt;/p&gt;

&lt;p&gt;Keeping it running turned up a bug I think a lot of REST clients have and nobody notices, because it&lt;br&gt;
doesn't look like a bug from either side.&lt;/p&gt;

&lt;p&gt;Here it is in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/events/{id}   with an empty id
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;becomes&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /v1/events/
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which is not a 404. It's the &lt;strong&gt;list&lt;/strong&gt; endpoint. It returns &lt;code&gt;200 OK&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What that did to my site
&lt;/h2&gt;

&lt;p&gt;Every change on my site lives at a URL like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/event/openai-gpt-4-deprecated-bf_20260723143458_0003
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slug for humans, id after the last hyphen for the lookup. The parser is exactly what you'd expect:&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;eventIdFromParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;param&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="kr"&gt;string&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;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lastIndexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;param&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;param&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now consider &lt;code&gt;/event/google-&lt;/code&gt;. A trailing hyphen, no id. Crawlers generate these. So do chat clients and&lt;br&gt;
mail readers that break a long URL across lines and leave the tail behind.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lastIndexOf('-')&lt;/code&gt; finds the final character, &lt;code&gt;slice(i + 1)&lt;/code&gt; returns &lt;code&gt;""&lt;/code&gt;, and the fetch goes out as&lt;br&gt;
&lt;code&gt;/v1/events/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The API answers &lt;code&gt;200&lt;/code&gt; with &lt;code&gt;{ data: [ …every recent event… ] }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My mapper then did what mappers do — it mapped:&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;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fromApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// j.data is an ARRAY here&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fromApi&lt;/code&gt; read &lt;code&gt;.title&lt;/code&gt;, &lt;code&gt;.providerName&lt;/code&gt;, &lt;code&gt;.severity&lt;/code&gt; off an array. All &lt;code&gt;undefined&lt;/code&gt;. &lt;strong&gt;No error yet:&lt;/strong&gt;&lt;br&gt;
reading a missing property off an array is perfectly legal. The page got an object shaped like an event&lt;br&gt;
whose every field was empty, rendered happily down the tree until it reached:&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="nx"&gt;providerName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and threw.&lt;/p&gt;

&lt;p&gt;So the URL returned &lt;strong&gt;500 where a 404 was owed&lt;/strong&gt;. &lt;code&gt;/event/google-&lt;/code&gt;, &lt;code&gt;/event/aws-&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;/ja/event/groq-groq-&lt;/code&gt; — all 500, live, for as long as they had existed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why it survived so long
&lt;/h2&gt;

&lt;p&gt;Two reasons, and the second is the interesting one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is invisible in the request logs.&lt;/strong&gt; I found this by querying Cloudflare's observability API for&lt;br&gt;
5xx responses, and the field I would naturally have filtered on was useless:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$workers&lt;/span&gt;.outcome &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ok"&lt;/span&gt;     ← &lt;span class="k"&gt;for &lt;/span&gt;every single one of them
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A rendered 500 is a &lt;em&gt;successful&lt;/em&gt; worker invocation. The worker ran, produced a response, returned it.&lt;br&gt;
That the response was an error page is not the worker's problem. The signal lives in&lt;br&gt;
&lt;code&gt;$metadata.error&lt;/code&gt;, not in the outcome. If you filter your edge logs by outcome, application-level 500s&lt;br&gt;
are simply not in your dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And a sibling route accidentally hid it.&lt;/strong&gt; The same data layer serves&lt;br&gt;
&lt;code&gt;/pricing/history/&amp;lt;slug&amp;gt;-&amp;lt;id&amp;gt;&lt;/code&gt;, and that route never 500'd. Not because it was written more carefully —&lt;br&gt;
because it happens to filter:&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;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pricing_changed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The junk object had &lt;code&gt;type: undefined&lt;/code&gt;, so the filter rejected it and the page 404'd correctly.&lt;br&gt;
Entirely by accident.&lt;/p&gt;

&lt;p&gt;That made the bug look route-specific. I spent time reading the event page, which was the one place&lt;br&gt;
the defect &lt;em&gt;wasn't&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The actual shape of the problem
&lt;/h2&gt;

&lt;p&gt;It isn't the parser, and it isn't the page. It's this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A REST detail path with a missing key silently degrades into the collection path.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;/things/{id}&lt;/code&gt; and &lt;code&gt;/things/&lt;/code&gt; are different endpoints with different response shapes, and the only&lt;br&gt;
thing separating them is a string you built by hand. When that string is empty, the URL you send is a&lt;br&gt;
&lt;em&gt;valid request for something else entirely&lt;/em&gt;, and it succeeds.&lt;/p&gt;

&lt;p&gt;No status code tells you. Both are &lt;code&gt;200&lt;/code&gt;. Both return &lt;code&gt;{ data: … }&lt;/code&gt;. The only difference is that one&lt;br&gt;
&lt;code&gt;data&lt;/code&gt; is an object and the other is an array — and JavaScript will let you read properties off both.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix, and where it goes
&lt;/h2&gt;

&lt;p&gt;Two guards, and it matters that they are in the data layer rather than in the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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="nx"&gt;CWEvent&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="c1"&gt;// An empty id is a not-found, not a request.&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;id&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;j&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;api&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/v1/events/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&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="c1"&gt;// Only a DTO that actually carries an id is an event. Anything else — a list payload,&lt;/span&gt;
  &lt;span class="c1"&gt;// `{data:null}` — is not-found, never a half-populated object handed to the renderer.&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;j&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fromApi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&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="c1"&gt;// `j === null` means the API was never reached at all (build time, or local dev with no base URL),&lt;/span&gt;
  &lt;span class="c1"&gt;// which is a different condition from "the API answered and there is no such event".&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;MOCK_EVENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first guard stops the malformed request being sent. The second stops a wrong-shaped response being&lt;br&gt;
trusted if one arrives anyway. The third line matters for a reason worth stating: &lt;strong&gt;"the API said no"&lt;br&gt;
and "I never reached the API" have to stay distinguishable&lt;/strong&gt;, or a build-time render quietly turns&lt;br&gt;
every page into a 404.&lt;/p&gt;

&lt;p&gt;Putting all this in &lt;code&gt;getEvent()&lt;/code&gt; rather than in the page component covers three call sites at once: the&lt;br&gt;
page, &lt;code&gt;generateMetadata&lt;/code&gt;, and the OpenGraph image route. Fixing it in the component would have left two&lt;br&gt;
of those still 500ing, and OG image failures are especially quiet — nobody notices a missing preview&lt;br&gt;
card until someone shares the link.&lt;/p&gt;

&lt;p&gt;All four URLs are 404s now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/event/google-                404
/event/aws-                   404
/ja/event/groq-groq-          404
/pricing/history/deepseek-    404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to find this in your own code
&lt;/h2&gt;

&lt;p&gt;The grep that would have found it for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a template literal that interpolates straight into a path segment&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s1"&gt;'`[^`]*/\$\{[A-Za-z_]+\}`'&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, for each hit, three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can that value ever be an empty string?&lt;/strong&gt; Anything derived from a URL segment, a regex capture, a
&lt;code&gt;split()&lt;/code&gt; or a &lt;code&gt;slice()&lt;/code&gt; can be. Mine came from &lt;code&gt;lastIndexOf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What does your API return for the collection path?&lt;/strong&gt; If it is a &lt;code&gt;200&lt;/code&gt; with a different shape, you
have this bug waiting. If it &lt;code&gt;404&lt;/code&gt;s or &lt;code&gt;405&lt;/code&gt;s, you don't. This is worth one curl:
&lt;code&gt;curl -i https://api.example.com/v1/things/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does your mapper verify the shape, or just read fields off it?&lt;/strong&gt; Reading &lt;code&gt;.id&lt;/code&gt; off an array
returns &lt;code&gt;undefined&lt;/code&gt; rather than throwing, so the failure surfaces far away from its cause — in my
case several components later, on a &lt;code&gt;.charAt(0)&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you own the API as well as the client, there is a fourth option that fixes it for every consumer at&lt;br&gt;
once: make the collection path reject a trailing slash instead of serving the list. I didn't, because&lt;br&gt;
the list endpoint is a real endpoint that real callers use — but if yours isn't, that's the cheaper fix.&lt;/p&gt;

&lt;p&gt;The one-line version: &lt;strong&gt;check the id before you build the URL, and check the response carries an id&lt;br&gt;
before you trust it.&lt;/strong&gt; Neither check is clever. Both were missing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found 2026-08-08, fixed the same day. The tracker this came out of is at&lt;br&gt;
&lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com&lt;/a&gt; — it watches AI vendor docs for changes, which is how it&lt;br&gt;
ends up with a lot of URLs that crawlers like to truncate.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>typescript</category>
      <category>api</category>
    </item>
    <item>
      <title>Google removed an announced Gemini 2.5 shutdown date with no changelog entry</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Thu, 13 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/the-same-claude-model-gives-you-60-days-notice-on-one-platform-and-184-on-another-347p</link>
      <guid>https://dev.to/ai_changewatch/the-same-claude-model-gives-you-60-days-notice-on-one-platform-and-184-on-another-347p</guid>
      <description>&lt;p&gt;&lt;strong&gt;On July 28, Google's Gemini API deprecation page said &lt;code&gt;gemini-2.5-pro&lt;/code&gt;, &lt;code&gt;gemini-2.5-flash&lt;/code&gt; and &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt; would shut down on October 16, 2026.&lt;/strong&gt; By August 3 the date was gone. The rows now read "No shutdown date announced", and the current page shows no sign that October 16 was ever there.&lt;/p&gt;

&lt;p&gt;I could not find the change announced anywhere — not in the Gemini API changelog, not in the release notes.&lt;/p&gt;

&lt;p&gt;Meanwhile Google Cloud's own lifecycle page still lists all three models with a shutdown date of &lt;strong&gt;October 20, 2026&lt;/strong&gt; — four days later than the one that disappeared. Both pages are live as I write this, and they give different answers for the same three models.&lt;/p&gt;

&lt;p&gt;That is the thread running through everything below: model lifecycle pages are not references you read once, they are data that moves — and comparing them across vendors turned out to be harder than collecting them.&lt;/p&gt;

&lt;p&gt;I found this while crawling what 15 AI vendors publish about their own models — deprecation tables, lifecycle pages, pricing and SDK releases — and recording every time one of them changes. I had assumed tracking model deprecations would be straightforward: find the vendor's lifecycle page, extract the model name and retirement date, keep it updated.&lt;/p&gt;

&lt;p&gt;It wasn't.&lt;/p&gt;

&lt;p&gt;Here are some things I found while trying to make the data comparable.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A previously announced shutdown date can disappear
&lt;/h2&gt;

&lt;p&gt;On July 28, Google's Gemini API deprecation page listed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;gemini-2.5-pro&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gemini-2.5-flash&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three had a shutdown date of October 16, 2026, along with replacement models.&lt;/p&gt;

&lt;p&gt;On August 3, the entries changed. Here is the &lt;code&gt;gemini-2.5-pro&lt;/code&gt; row:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;July 28&lt;/th&gt;
&lt;th&gt;August 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Shutdown date&lt;/td&gt;
&lt;td&gt;October 16, 2026&lt;/td&gt;
&lt;td&gt;No shutdown date announced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replacement&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;deprecated&lt;/td&gt;
&lt;td&gt;unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The other two rows changed the same way, each losing its own replacement (&lt;code&gt;gemini-3.6-flash&lt;/code&gt; for &lt;code&gt;gemini-2.5-flash&lt;/code&gt;, &lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt; for &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The current page doesn't show that October 16 was ever announced.&lt;/p&gt;

&lt;p&gt;There is no change history and no note saying the date was withdrawn. The only signal is the &lt;code&gt;Last updated&lt;/code&gt; stamp at the foot of the page, which moved from &lt;code&gt;2026-07-30 UTC&lt;/code&gt; to &lt;code&gt;2026-08-03 UTC&lt;/code&gt;. It tells you &lt;em&gt;that&lt;/em&gt; something changed, not &lt;em&gt;what&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;You don't have to take my word for the earlier state — the Internet Archive holds both sides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://web.archive.org/web/20260802004009/https://ai.google.dev/gemini-api/docs/deprecations" rel="noopener noreferrer"&gt;August 2 capture&lt;/a&gt; (&lt;code&gt;Last updated 2026-07-30 UTC&lt;/code&gt;): all three rows read &lt;code&gt;October 16, 2026&lt;/code&gt;, each with a replacement.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://web.archive.org/web/20260804183351/https://ai.google.dev/gemini-api/docs/deprecations" rel="noopener noreferrer"&gt;August 4 capture&lt;/a&gt; (&lt;code&gt;Last updated 2026-08-03 UTC&lt;/code&gt;): all three read &lt;code&gt;No shutdown date announced&lt;/code&gt;, replacement column empty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the same table, &lt;code&gt;gemini-2.5-flash-image&lt;/code&gt; reads &lt;code&gt;October 2, 2026&lt;/code&gt; in both captures. Three rows changed; the page wasn't rebuilt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Was the change announced anywhere?
&lt;/h3&gt;

&lt;p&gt;The deprecation page itself says where to look:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Deprecation announcements are made on the Release notes page, and the announced earliest shutdown dates are tracked on this page.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The &lt;a href="https://ai.google.dev/gemini-api/docs/changelog" rel="noopener noreferrer"&gt;Gemini API release notes&lt;/a&gt; are current — &lt;code&gt;Last updated 2026-08-11&lt;/code&gt;, most recent entry July 30 — and mention neither October 16 nor its withdrawal.&lt;/p&gt;

&lt;p&gt;There is another place to look. Google serves these same models through Google Cloud as well, where the Vertex AI documentation has moved to Gemini Enterprise Agent Platform. &lt;strong&gt;That lifecycle table never withdrew the date.&lt;/strong&gt; As of August 12 it still reads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Retirement date&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-pro&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;October 20, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;October 20, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;October 20, 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not October 16. October &lt;strong&gt;20&lt;/strong&gt;. Archive captures of the two pages put that shift a few days ahead of the withdrawal:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capture date&lt;/th&gt;
&lt;th&gt;&lt;a href="https://docs.cloud.google.com/gemini-enterprise-agent-platform/models/model-versions" rel="noopener noreferrer"&gt;Google Cloud&lt;/a&gt;&lt;/th&gt;
&lt;th&gt;&lt;a href="https://ai.google.dev/gemini-api/docs/deprecations" rel="noopener noreferrer"&gt;Gemini API&lt;/a&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;June 10&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Not before October 16, 2026&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;October 16, 2026&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July 18–19&lt;/td&gt;
&lt;td&gt;&lt;code&gt;October 16, 2026&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;October 16, 2026&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;July 30&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;October 20, 2026&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;October 16, 2026&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;August 4 →&lt;/td&gt;
&lt;td&gt;&lt;code&gt;October 20, 2026&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;No shutdown date announced&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So Google Cloud moved the date by four days, and days later the Gemini API page dropped it altogether. I could not find either change announced. The Agent Platform release notes are actively maintained — most recent entry August 4 — and don't mention it. The only announcement of October 16 I found anywhere is an April 2, 2026 entry in the &lt;em&gt;old&lt;/em&gt; Vertex AI release notes ("The retirement dates for Gemini 2.5 Pro, Gemini 2.5 Flash-Lite, and Gemini 2.5 Flash have been updated to October 16, 2026") — on a page that now carries the banner &lt;strong&gt;"Vertex AI documentation is no longer being updated"&lt;/strong&gt; and whose most recent entry is May 26.&lt;/p&gt;

&lt;p&gt;That leaves two live answers for the same three models. Read the Gemini API docs: no shutdown date. Read Google Cloud: October 20, 2026.&lt;/p&gt;

&lt;p&gt;Which is section 1's problem again — two platforms, two lifecycles, each page correct about its own endpoint — except this time it's one vendor. That's a defensible way to run it. It's still two answers, and neither page tells you it isn't the only one.&lt;/p&gt;

&lt;p&gt;I noticed this because I had captured the earlier state.&lt;/p&gt;

&lt;p&gt;That changed how I thought about the problem.&lt;/p&gt;

&lt;p&gt;A deprecation page isn't just documentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's changing data.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you only store the current page, you can lose the fact that a vendor previously announced something.&lt;/p&gt;

&lt;p&gt;The only way to catch it is to keep the earlier observations, not just the current page.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The same model gives you 3× the warning on one platform
&lt;/h2&gt;

&lt;p&gt;How long do you get between "this model is going away" and "this model stops answering"?&lt;/p&gt;

&lt;p&gt;Two vendors publish the answer as policy. Anthropic:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Anthropic notifies customers with active deployments for models with upcoming retirements, providing &lt;strong&gt;at least 60 days' notice&lt;/strong&gt; before model retirement for publicly released models.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AWS Bedrock:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A model will be in the Legacy state for &lt;strong&gt;at least 6 months&lt;/strong&gt; before the EOL date.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Both of them also publish the dates you need to check it. Anthropic dates each retirement announcement in its deprecation history ("On June 5, 2026, Anthropic notified developers…") and gives the retirement date in the table under it. AWS puts a &lt;code&gt;Legacy date&lt;/code&gt; next to the &lt;code&gt;EOL date&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the notice period here is one subtraction, and both ends of it come from the vendor: &lt;strong&gt;start = the date the vendor says it announced the retirement (Anthropic) or moved the model to Legacy (AWS); end = the retirement or EOL date it published.&lt;/strong&gt; No estimate, no first-seen date of my own.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Retirements with both dates&lt;/th&gt;
&lt;th&gt;Observed notice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;60–189 days, median 63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Bedrock&lt;/td&gt;
&lt;td&gt;17 (every row)&lt;/td&gt;
&lt;td&gt;181–185 days, median 184&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Anthropic's spread is worth a second look: seven of the 19 ran 63 days, five ran 60–62, and the long tail (181, 181, 181, 189) is all from 2025. The five most recent are 60, 61, 62, 62 and 62 — the stated floor, near enough exactly.&lt;/p&gt;

&lt;p&gt;AWS lands on six months in every single row, across five different model providers — AI21, Amazon, Anthropic, Cohere and TwelveLabs. That's a platform-wide rule, not a coincidence.&lt;/p&gt;

&lt;p&gt;Now look at the models that appear on both:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Notice from Anthropic&lt;/th&gt;
&lt;th&gt;Notice from AWS Bedrock&lt;/th&gt;
&lt;th&gt;Ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 4.1&lt;/td&gt;
&lt;td&gt;61 days&lt;/td&gt;
&lt;td&gt;184 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.0×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 4&lt;/td&gt;
&lt;td&gt;62 days&lt;/td&gt;
&lt;td&gt;183 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.0×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude 3 Haiku&lt;/td&gt;
&lt;td&gt;60 days&lt;/td&gt;
&lt;td&gt;184 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude 3.7 Sonnet&lt;/td&gt;
&lt;td&gt;114 days&lt;/td&gt;
&lt;td&gt;181 days&lt;/td&gt;
&lt;td&gt;1.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, same retirement, three times the runway — depending on which endpoint your code calls.&lt;/p&gt;

&lt;p&gt;Two things before concluding that Bedrock is simply more generous.&lt;/p&gt;

&lt;p&gt;AWS also guarantees a model stays available &lt;strong&gt;at least 12 months from launch&lt;/strong&gt;, which Anthropic doesn't publish at all. But the back half of that six-month window isn't free:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;During this public extended access period, active users of a Legacy model can continue to use it until the EOL date (for a minimum of 3 months), but &lt;strong&gt;you should expect higher pricing&lt;/strong&gt;, which will be set by the model provider.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So part of the longer grace period is a paid extension.&lt;/p&gt;

&lt;p&gt;And the other two vendors can't be measured. &lt;strong&gt;OpenAI and Google publish a shutdown date and no announcement date&lt;/strong&gt;, so there's no start of the clock to subtract from. Google's table does carry a &lt;code&gt;Release date&lt;/code&gt;, but that's when the model launched, not when it was marked for retirement. Whatever notice they give, their own pages don't say.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. There is no standard "replacement model" field
&lt;/h2&gt;

&lt;p&gt;Once a model is deprecated, the next question is what to migrate to. There is no common answer format.&lt;/p&gt;

&lt;p&gt;AWS Bedrock's lifecycle table has no replacement column at all:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Model provider | Model name | Model ID | Regions | Legacy date | EOL date | Public extended access start date&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It tells developers to move to the latest model before EOL, without naming a successor there.&lt;/p&gt;

&lt;p&gt;Other vendors do provide replacement information:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vendor&lt;/th&gt;
&lt;th&gt;Rows checked&lt;/th&gt;
&lt;th&gt;Rows with replacement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;140&lt;/td&gt;
&lt;td&gt;134 (the other 6 print "—")&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;42 with shutdown dates&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;17 with EOL dates&lt;/td&gt;
&lt;td&gt;No replacement field&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This isn't necessarily a criticism of AWS. AWS hosts models from other providers, so recommending a particular successor can be a different product decision.&lt;/p&gt;

&lt;p&gt;But the three that do publish it don't agree on where it goes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI&lt;/strong&gt; puts it in the deprecation table itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google&lt;/strong&gt; puts it in the deprecation table too — but only fills it in for rows that have a shutdown date. Rows marked &lt;code&gt;No shutdown date announced&lt;/code&gt; mostly leave it empty, so "deprecated" and "scheduled" are separate states and only the second one tells you anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic&lt;/strong&gt; doesn't put it in the model status table at all; that table has no such column. It's in separate tables further down the same page, one per announcement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS&lt;/strong&gt; has no field for it.&lt;/p&gt;

&lt;p&gt;And OpenAI isn't consistent with itself: the same page uses &lt;code&gt;Recommended replacement&lt;/code&gt; and &lt;code&gt;Substitute model&lt;/code&gt; for the same concept. A human reads straight past that; a parser has to be told. Mine matched &lt;code&gt;replacement&lt;/code&gt;, &lt;code&gt;successor&lt;/code&gt;, &lt;code&gt;recommended&lt;/code&gt; and &lt;code&gt;alternative&lt;/code&gt; — but not &lt;code&gt;substitute&lt;/code&gt; — so it silently lost replacement information for 26 models. The rows still had shutdown dates, so nothing looked broken; my site just showed retirements with no migration target.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;That was my bug, not OpenAI's.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Four vendors, four structures, and one of them uses two inside a single document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Update — August 15: a security fix did the same thing
&lt;/h2&gt;

&lt;p&gt;On August 15, two days after this article went up, I watched the same pattern somewhere else entirely.&lt;/p&gt;

&lt;p&gt;Claude Code &lt;code&gt;v2.1.232&lt;/code&gt; (August 13) listed this among its fixes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Fixed a Windows permission bypass where Git Bash followed Cygwin-style symlinks that path validation saw as regular files; writes through them now require permission&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;v2.1.233&lt;/code&gt;, published about 23 hours later, says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reverted the 2.1.232 Bash permission changes for Cygwin-style symlinks on Windows and for input redirections (&lt;code&gt;&amp;lt; file&lt;/code&gt;); a narrower version will return in a later release&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same release also fixes what it calls "a 2.1.232 regression" — auto mode repeatedly stopping for manual approval on ordinary &lt;code&gt;cd &amp;lt;dir&amp;gt; &amp;amp;&amp;amp; &amp;lt;command&amp;gt; &amp;gt; file&lt;/code&gt; commands. Which is presumably why the change was pulled: the tightening caught legitimate commands too.&lt;/p&gt;

&lt;p&gt;Nothing here is hidden. Both lines are in the public release notes, and the revert is stated plainly.&lt;/p&gt;

&lt;p&gt;But if you read the 2.1.232 notes on the 13th and upgraded for that specific hardening, the thing you upgraded for stopped being true on the 14th — and the only place that says so is the next release's notes, which you have no particular reason to read if you already upgraded.&lt;/p&gt;

&lt;p&gt;Same shape as the Google case above: a published fact replaced by a later page. The differences are that the window was a day rather than a week, the vendor said so explicitly rather than silently, and it was a security fix rather than a date.&lt;/p&gt;

&lt;p&gt;I've amended our own entry for 2.1.232 to record the revert, which is the part I'd have got wrong if I'd only read it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What surprised me
&lt;/h2&gt;

&lt;p&gt;Before building this, I thought the difficult part would be collecting the data.&lt;/p&gt;

&lt;p&gt;It isn't. Collecting it is a crawler and a parser.&lt;/p&gt;

&lt;p&gt;A shutdown date can be announced and later disappear from the current page.&lt;/p&gt;

&lt;p&gt;A model can have a different amount of notice depending on the platform.&lt;/p&gt;

&lt;p&gt;A replacement model might be published—or not.&lt;/p&gt;

&lt;p&gt;It might be in a different table, under a different field name, or only appear after another lifecycle state changes.&lt;/p&gt;

&lt;p&gt;And the documentation itself changes while you're trying to monitor it.&lt;/p&gt;

&lt;p&gt;The result is that answering a seemingly simple question—&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"When does this model stop working, and what should I use instead?"&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;—can require multiple pages, vendor-specific parsing, and historical state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hard part wasn't collecting the data. It was making the data comparable.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes on the numbers
&lt;/h2&gt;

&lt;p&gt;The numbers in this article are a snapshot taken on August 11, 2026.&lt;/p&gt;

&lt;p&gt;Since then the AWS table has already shrunk. It listed 17 rows on August 11 and lists &lt;strong&gt;13 today&lt;/strong&gt;, because a row is removed once its EOL date passes — including the &lt;code&gt;claude-3-7-sonnet&lt;/code&gt; row this article compares, so that one line can no longer be checked against the live page. Cohere's Command R and R+ reach EOL today, which will take it to 11. That is the article's point arriving on schedule.&lt;/p&gt;

&lt;p&gt;The cross-vendor comparisons describe what was publicly documented at that point, not a complete historical record.&lt;/p&gt;

&lt;p&gt;For the Google example, I have been continuously monitoring the relevant page since July 28.&lt;/p&gt;

&lt;p&gt;I also deliberately left out figures where I couldn't distinguish between:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;the vendor not publishing the information, and&lt;/li&gt;
&lt;li&gt;my collector failing to extract it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've already had cases where the second explanation turned out to be true.&lt;/p&gt;

&lt;p&gt;That was a useful lesson too.&lt;/p&gt;

&lt;p&gt;AI Change Watch: &lt;a href="https://aichangewatch.com/deprecations?src=devto" rel="noopener noreferrer"&gt;https://aichangewatch.com/deprecations?src=devto&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aws</category>
      <category>llm</category>
      <category>devops</category>
    </item>
    <item>
      <title>Next.js ISR Wasn't Revalidating on Cloudflare Workers — and `x-nextjs-cache: HIT` Lied to Me</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 11 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn</link>
      <guid>https://dev.to/ai_changewatch/x-nextjs-cache-hit-doesnt-prove-your-isr-is-working-3lmn</guid>
      <description>&lt;p&gt;I spent months looking at &lt;code&gt;x-nextjs-cache: HIT&lt;/code&gt; and thinking ISR was working. It wasn't.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;AI Change Watch&lt;/a&gt;, a Next.js App Router site on Cloudflare Workers via OpenNext. It crawls AI vendor docs and pricing pages and publishes what changed. Every page carries &lt;code&gt;export const revalidate = 300&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Background revalidation had never run. Not "ran slowly" — never ran, not once. And the header I checked to convince myself it was fine is structurally incapable of telling me otherwise.&lt;/p&gt;

&lt;p&gt;Here is what was actually broken, why the header can't prove what I thought it proved, and the piece that's easiest to forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that isn't a symptom
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;revalidate = 300&lt;/code&gt; was decorative. Pages &lt;em&gt;did&lt;/em&gt; refresh, so nothing looked wrong.&lt;/p&gt;

&lt;p&gt;They refreshed because &lt;strong&gt;a deploy changes the buildId, and the buildId is part of the R2 key space&lt;/strong&gt; — so every deploy invalidated the whole cache. This repo deploys several times a day. The bug was covered by deployment frequency.&lt;/p&gt;

&lt;p&gt;The logs told the real story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE
  107  Failed to revalidate stale page      (3-day window, earliest 2026-08-03)
    0  successful background revalidations

AFTER
  196  events
    0  errors
   33  revalidate runs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;33 re-renders that no user request triggered. That is what "working" looks like.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three pieces have to line up
&lt;/h2&gt;

&lt;p&gt;For Durable Object-backed ISR revalidation in this setup, three separate things need to be configured, and they are three different kinds of thing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Queue configuration&lt;/strong&gt; — &lt;code&gt;queue: doQueue&lt;/code&gt; in &lt;code&gt;open-next.config.ts&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Durable Object binding&lt;/strong&gt; — &lt;code&gt;NEXT_CACHE_DO_QUEUE&lt;/code&gt; + its migration, in &lt;code&gt;wrangler.jsonc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A Worker service binding&lt;/strong&gt; — &lt;code&gt;WORKER_SELF_REFERENCE&lt;/code&gt;, also in &lt;code&gt;wrangler.jsonc&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I had none of them. The binding name and the class name are fixed by the adapter — they are not yours to choose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that's easiest to forget
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;WORKER_SELF_REFERENCE&lt;/code&gt; fails in the most misleading way of the three.&lt;/p&gt;

&lt;p&gt;The Durable Object does not render anything itself. It calls &lt;em&gt;back into your Worker&lt;/em&gt; to do the render. Without the service binding, the job reaches the queue, but the revalidation worker cannot call back into the Worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No service binding for cache revalidation worker
53 occurrences in 8 minutes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chain is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  request ──&amp;gt; Worker ──&amp;gt; stale entry found
                 │
                 └─&amp;gt; enqueue ──&amp;gt; DO (NEXT_CACHE_DO_QUEUE)
                                   │
                                   └─&amp;gt; WORKER_SELF_REFERENCE ──&amp;gt; Worker renders
                                                                      │
                                            R2 &amp;lt;── writes fresh entry ─┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cut the self-reference and it dies at step 3 — but steps 1 and 2 still report success, so nothing surfaces as an error at the request path. Pages stay 200, because a MISS still renders through NextServer. The symptom is silent staleness, not an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the header proves nothing
&lt;/h2&gt;

&lt;p&gt;This is the part that cost me the most time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;x-nextjs-cache: MISS&lt;/code&gt; &lt;strong&gt;still renders through NextServer and still writes a cache entry.&lt;/strong&gt; So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You hammer a page. First response: MISS.&lt;/li&gt;
&lt;li&gt;It renders and writes.&lt;/li&gt;
&lt;li&gt;Every subsequent response: HIT.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You now have a page reading HIT whether or not the revalidation queue exists. Sampling cache state cannot distinguish "the background queue re-rendered this" from "somebody's request repopulated it." Both produce HIT. Both produce fresh-looking content.&lt;/p&gt;

&lt;p&gt;I checked HIT across the site and concluded ISR was healthy. It was not, and the header was never going to tell me.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Judge on the logs instead.&lt;/strong&gt; Filter Workers Logs on &lt;code&gt;$metadata.level = error&lt;/code&gt; for failures, and count the &lt;code&gt;revalidate&lt;/code&gt; info lines for actual DO-driven re-renders. That's where the 33 above comes from.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;enableCacheInterception&lt;/code&gt; made it worse
&lt;/h2&gt;

&lt;p&gt;If you don't set &lt;code&gt;queue&lt;/code&gt; at all, OpenNext falls back to a &lt;strong&gt;dummy queue whose &lt;code&gt;send()&lt;/code&gt; throws&lt;/strong&gt;. Normally that throw is caught inside NextServer, so you get a log line and a stale page.&lt;/p&gt;

&lt;p&gt;Then I enabled &lt;code&gt;enableCacheInterception: true&lt;/code&gt; for the CPU savings. That serves ISR hits from the routing layer, skipping NextServer — which also moves the same throw &lt;em&gt;outside&lt;/em&gt; NextServer's catch, and before the render:&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="nb"&gt;Error&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;routingHandler&lt;/span&gt;
  &lt;span class="nx"&gt;at&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;send &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;116290&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="nf"&gt;computeCacheControl &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;120329&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="nf"&gt;generateResult &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;120394&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;at&lt;/span&gt; &lt;span class="nf"&gt;cacheInterceptor &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;js&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;121493&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two properties made this much worse than a normal bug. It threw &lt;em&gt;before&lt;/em&gt; the render, so the entry could never refresh and the 500 was permanent per URL. And it fired only once an entry passed &lt;code&gt;revalidate&lt;/code&gt;, so pages went down &lt;strong&gt;one at a time over roughly 9 hours — 12 URLs&lt;/strong&gt; before I reverted it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The flag is not the villain; the order is.&lt;/strong&gt; The CPU win is real. Confirm revalidation works &lt;em&gt;first&lt;/em&gt;, then turn it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wrong hypothesis: &lt;code&gt;/en/&lt;/code&gt; → 307
&lt;/h2&gt;

&lt;p&gt;Worth recording because it was well-argued and still wrong.&lt;/p&gt;

&lt;p&gt;After the fix, &lt;code&gt;Failed to revalidate stale page /en/...&lt;/code&gt; still appeared occasionally. Every failing path was under &lt;code&gt;/en/&lt;/code&gt;, and &lt;code&gt;middleware.ts&lt;/code&gt; issues a &lt;code&gt;307&lt;/code&gt; from &lt;code&gt;/en/*&lt;/code&gt; to the unprefixed canonical. Obvious conclusion: the redirect breaks the revalidation fetch.&lt;/p&gt;

&lt;p&gt;Controlled test — hammer 6 &lt;code&gt;/en/&lt;/code&gt; pages and the 6 equivalent &lt;code&gt;/ja/&lt;/code&gt; pages past their revalidate window, 8 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;83 successful revalidations
 0 failures
 both locales
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the 307 broke revalidation, &lt;code&gt;/en/&lt;/code&gt; would have failed dozens of times. It failed zero. The hypothesis died, and the middleware redirect — which is correct canonicalisation — stayed. Deleting it on a plausible-sounding theory would have bought a duplicate-URL problem for nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data actually showed
&lt;/h2&gt;

&lt;p&gt;The residual failures correlate with &lt;strong&gt;deploy churn&lt;/strong&gt;. All four in that window landed 2.0–4.2 minutes after a deploy, during a stretch with four deploys in 22 minutes. They self-heal on the next request (MISS → render → write), never return 5xx, and did not occur at all in a steady period.&lt;/p&gt;

&lt;p&gt;The mechanism I &lt;em&gt;believe&lt;/em&gt; is behind that: a deploy changes the buildId and with it the R2 key space, so a revalidation enqueued across the switch has nowhere to land. See the caveat at the end — I have not proven this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration that actually works
&lt;/h2&gt;

&lt;p&gt;Both files, complete, as they run in production today.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// web/open-next.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineCloudflareConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opennextjs/cloudflare&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;r2IncrementalCache&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;withRegionalCache&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opennextjs/cloudflare/overrides/incremental-cache/regional-cache&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;doQueue&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@opennextjs/cloudflare/overrides/queue/do-queue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineCloudflareConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;incrementalCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;withRegionalCache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r2IncrementalCache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long-lived&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;doQueue&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// web/wrangler.jsonc  (only the parts relevant to ISR)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"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;"changewatch-web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".open-next/worker.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-03-25"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compatibility_flags"&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="s2"&gt;"nodejs_compat"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"observability"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;

  &lt;/span&gt;&lt;span class="c1"&gt;// The incremental cache store itself.&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"r2_buckets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NEXT_INC_CACHE_R2_BUCKET"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"bucket_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;"awc-web-cache"&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="c1"&gt;// 2. The queue. Name and class are fixed by the adapter.&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"durable_objects"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bindings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"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;"NEXT_CACHE_DO_QUEUE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"class_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;"DOQueueHandler"&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="nl"&gt;"migrations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"new_sqlite_classes"&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="s2"&gt;"DOQueueHandler"&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="c1"&gt;// 3. The self-reference. `service` must equal `name` at the top of this file.&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"services"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"binding"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"WORKER_SELF_REFERENCE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"changewatch-web"&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;Two notes on this config. &lt;code&gt;new_sqlite_classes&lt;/code&gt; is what the adapter's handler expects and is the only option for new Durable Object classes. And &lt;code&gt;mode: 'long-lived'&lt;/code&gt; on the regional cache is a separate lever from revalidation — &lt;code&gt;short-lived&lt;/code&gt; pins every regional entry at 60s, which on this site was worth 300–380ms of response time. Different problem, same file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;Three separate meters, none of them your Workers CPU budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Meter&lt;/th&gt;
&lt;th&gt;Included&lt;/th&gt;
&lt;th&gt;Then&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Durable Object requests&lt;/td&gt;
&lt;td&gt;1M/mo&lt;/td&gt;
&lt;td&gt;$0.15/M&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable Object duration&lt;/td&gt;
&lt;td&gt;400k GB-s (hibernating objects not billed)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;R2 Class A operations&lt;/td&gt;
&lt;td&gt;1M/mo&lt;/td&gt;
&lt;td&gt;$4.50/M&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;Prices are Cloudflare's published rates as of August 2026 — check &lt;a href="https://developers.cloudflare.com/durable-objects/platform/pricing/" rel="noopener noreferrer"&gt;Durable Objects pricing&lt;/a&gt; and &lt;a href="https://developers.cloudflare.com/r2/pricing/" rel="noopener noreferrer"&gt;R2 pricing&lt;/a&gt; for current values.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Worst case here is roughly $0–1/month against ~$5.5–6.5 of Worker CPU. R2 &lt;em&gt;storage&lt;/em&gt; does not grow, because revalidation overwrites the same key. If DO requests ever approach the included 1M/mo, raise &lt;code&gt;revalidate&lt;/code&gt; — it scales all three meters together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;For this setup, all three pieces need to be configured correctly for background revalidation to work end-to-end: &lt;code&gt;queue: doQueue&lt;/code&gt;, the Durable Object binding with its migration, and &lt;code&gt;WORKER_SELF_REFERENCE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never conclude anything from &lt;code&gt;x-nextjs-cache&lt;/code&gt;. A MISS repopulates, so everything reads HIT eventually.&lt;/li&gt;
&lt;li&gt;Verify by counting &lt;code&gt;revalidate&lt;/code&gt; runs in the logs, not by sampling headers.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Failed to revalidate&lt;/code&gt; shortly after a deploy &lt;em&gt;may&lt;/em&gt; be deploy churn. Don't treat it as an ISR outage unless it persists during a stable deployment period.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;enableCacheInterception&lt;/code&gt; only after revalidation is confirmed working.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I still haven't proven
&lt;/h2&gt;

&lt;p&gt;Two things, so nobody takes them from this post as established:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deploy-churn explanation is a working hypothesis, not a result.&lt;/strong&gt; The failures correlated strongly with rapid deploys — all four within 2.0–4.2 minutes of one — and the buildId key-space rotation fits that timing. But I have not instrumented the R2 key at enqueue time to show that the key being written is the pre-deploy one. If you see the same pattern during a &lt;em&gt;stable&lt;/em&gt; period with no deploys, my explanation doesn't cover your case and it's worth digging.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One failure mode is still open here:&lt;/strong&gt; &lt;code&gt;Failed to set to cache Error: put: Reduce your concurrent requests&lt;/code&gt; — R2 rate-limiting ISR writes during crawler sweeps, 17 occurrences over three days. Unresolved at the time of writing.&lt;/p&gt;

&lt;p&gt;Docs worth reading properly rather than skimming: &lt;a href="https://opennext.js.org/cloudflare/caching" rel="noopener noreferrer"&gt;OpenNext Cloudflare caching&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/workers/runtime-apis/bindings/service-bindings/" rel="noopener noreferrer"&gt;Cloudflare service bindings&lt;/a&gt;, &lt;a href="https://developers.cloudflare.com/durable-objects/" rel="noopener noreferrer"&gt;Durable Objects&lt;/a&gt;, &lt;a href="https://nextjs.org/docs/app/guides/incremental-static-regeneration" rel="noopener noreferrer"&gt;Next.js ISR&lt;/a&gt;, and &lt;a href="https://developers.cloudflare.com/workers/observability/logs/workers-logs/" rel="noopener noreferrer"&gt;Workers Logs&lt;/a&gt; for the verification step.&lt;/p&gt;

&lt;p&gt;The site this came from tracks AI model and pricing changes across vendors: &lt;a href="https://aichangewatch.com/changes/model?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com/changes/model&lt;/a&gt;. Every page on it is served by the setup above — which is how I found out it was broken.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>serverless</category>
    </item>
    <item>
      <title>IndexNow Returns 429 from Cloudflare Workers: What Actually Fixed It</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/indexnow-returns-429-from-cloudflare-workers-what-actually-fixed-it-2955</link>
      <guid>https://dev.to/ai_changewatch/indexnow-returns-429-from-cloudflare-workers-what-actually-fixed-it-2955</guid>
      <description>&lt;p&gt;I build and run &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;AI Change Watch&lt;/a&gt;: it crawls 15 AI vendors — OpenAI, Anthropic, Google, AWS Bedrock, Azure and the rest — and records every model deprecation, API change and price move as a dated, searchable event. That works out to a couple of thousand pages, each changing on its own schedule rather than on mine.&lt;/p&gt;

&lt;p&gt;Building it also generates a steady supply of problems that took me longer to work out than they should have, so I'm going to start writing them up here: Cloudflare Workers, crawling at scale, edge caching, and the indexing plumbing that ties them together. This is the first one.&lt;/p&gt;

&lt;p&gt;That volatility is what makes &lt;a href="https://www.indexnow.org/" rel="noopener noreferrer"&gt;IndexNow&lt;/a&gt; a pretty natural fit. You POST a list of URLs, and Bing, Yandex, Naver and Seznam learn that those pages changed — no crawl budget negotiation, no waiting. It costs nothing and takes about twenty lines of code.&lt;/p&gt;

&lt;p&gt;I run it from a &lt;a href="https://developers.cloudflare.com/workers/" rel="noopener noreferrer"&gt;Cloudflare Worker&lt;/a&gt;. For weeks, the worker reported success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bing had never received a single URL.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The submitter that couldn't tell you it was broken
&lt;/h2&gt;

&lt;p&gt;The first problem wasn't network-level at all. It was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;submitIndexNow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&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="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt; &lt;span class="o"&gt;=&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;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10000&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;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// &amp;lt;-- nothing to send&lt;/span&gt;
  &lt;span class="c1"&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;endpoint&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;ENDPOINTS&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                          &lt;span class="c1"&gt;// &amp;lt;-- everything rejected us&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those &lt;code&gt;false&lt;/code&gt; returns surfaced to the caller as &lt;code&gt;submitted: 0&lt;/code&gt;. "There was nothing new to send" and "every endpoint refused the batch" were the same observable event. A quiet day and a completely broken submitter looked identical in the logs, which is why this went unnoticed for weeks.&lt;/p&gt;

&lt;p&gt;So before diagnosing anything, I made the two states say different things:&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="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;indexnow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endpoint&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="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The very next run printed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"indexnow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://www.bing.com/indexnow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"urls"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;errorCode&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;TooManyRequests&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;message&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;We're sorry, but you have sent too many requests to us recently.&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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;Bing was returning &lt;strong&gt;429 TooManyRequests&lt;/strong&gt;. Every time. And because my endpoint list falls through on failure, the batch was quietly landing on Yandex instead — the one search engine I wasn't submitting for.&lt;/p&gt;

&lt;p&gt;If you take one thing from this post: &lt;strong&gt;never let "nothing to do" and "the request failed" produce the same log line.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving it's the sender, not the payload
&lt;/h2&gt;

&lt;p&gt;A 429 usually means "slow down", so the obvious suspects are batch size, frequency, or a bad host/key pair.&lt;/p&gt;

&lt;p&gt;To rule those out, I kept the payload, the &lt;code&gt;key&lt;/code&gt;, the &lt;code&gt;keyLocation&lt;/code&gt; and the timing constant, and changed only where the request originated. All three of these went out within the same minute:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sender&lt;/th&gt;
&lt;th&gt;bing.com&lt;/th&gt;
&lt;th&gt;api.indexnow.org&lt;/th&gt;
&lt;th&gt;yandex.com&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Worker&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;429&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;429&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Residential IP (my laptop)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions runner&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;200&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same key. Same body. Same minute. The only variable was the sender.&lt;/p&gt;

&lt;p&gt;Volume isn't the trigger either. Going back through older logs, a &lt;strong&gt;4-URL&lt;/strong&gt; batch got the same 429 three days earlier. A 150-URL batch gets it today. There is no batch size small enough to slip under it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this proves, and what it doesn't
&lt;/h2&gt;

&lt;p&gt;Worth separating, because the two are not the same strength of claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What the experiment establishes:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same submission succeeds or fails depending purely on where it is sent from.&lt;/li&gt;
&lt;li&gt;The rejection is not a function of batch size, and not a problem with the key or &lt;code&gt;keyLocation&lt;/code&gt; — those were identical in all three runs.&lt;/li&gt;
&lt;li&gt;Therefore no amount of backoff, jitter, or chunking &lt;em&gt;on my side&lt;/em&gt; would have fixed it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What I'm inferring:&lt;/strong&gt; that this is about shared egress IP reputation or per-IP rate limiting. Cloudflare Workers egress from address ranges shared with a very large number of other tenants, many of whom also submit to IndexNow, so a per-client limit would be consumed collectively.&lt;/p&gt;

&lt;p&gt;That's a working hypothesis, not a demonstrated fact. Bing doesn't publish how its IndexNow endpoint rate-limits, and I have no way to observe it from outside. The &lt;a href="https://www.bing.com/indexnow/getstarted" rel="noopener noreferrer"&gt;official Bing IndexNow docs&lt;/a&gt; don't cover this case.&lt;/p&gt;

&lt;p&gt;Fortunately the fix doesn't depend on the hypothesis being right. Whatever the mechanism, the actionable finding is the same: the request has to leave from somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: move the POST, keep the policy
&lt;/h2&gt;

&lt;p&gt;The obvious move is "submit from somewhere else". The trap is rewriting the whole submitter in that somewhere else.&lt;/p&gt;

&lt;p&gt;My Worker doesn't just POST a list. It decides &lt;em&gt;what&lt;/em&gt; to send: it reads the site's own sitemap, ranks URLs by whether they've never been submitted, whether their &lt;code&gt;lastmod&lt;/code&gt; moved, and how long since the last submission, caps the run, and records what landed in KV. That logic is derived from the site's indexing policy. Duplicating it in a CI script means two copies that drift the first time either side changes.&lt;/p&gt;

&lt;p&gt;So I split the use case in two, and left both halves in the Worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Reads the sitemap, picks the batch. Records NOTHING.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SitemapPlan&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Records a batch as sent. Called only after it was accepted.&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SitemapUrl&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;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* ... */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exposed as two endpoints, the &lt;a href="https://docs.github.com/en/actions" rel="noopener noreferrer"&gt;GitHub Actions&lt;/a&gt; job becomes a courier that never interprets what it carries:&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;plan&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;admin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/admin/indexnow/pending?scope=sitemap&lt;/span&gt;&lt;span class="dl"&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;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sitemap-unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;the sitemap could not be read&lt;/span&gt;&lt;span class="dl"&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;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing-due&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;urlList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch&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;u&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;u&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="c1"&gt;// ... POST urlList to the IndexNow endpoints ...&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;accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`every endpoint rejected &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;urlList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; URLs`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Hand the batch straight back, verbatim.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/admin/indexnow/commit&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batch&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;Two details that matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;plan()&lt;/code&gt; records nothing.&lt;/strong&gt; If the submission fails, the same URLs come back in the next plan. An earlier version marked URLs as sent &lt;em&gt;before&lt;/em&gt; the POST, which meant one rejected batch silently retired 150 URLs for the whole refresh window — while reporting success.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;commit()&lt;/code&gt; re-reads its state and drops foreign hosts.&lt;/strong&gt; The batch makes a round trip through an external job, so on the way back it is untrusted input, not a trusted snapshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it fits together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   Cloudflare Worker  ──plan()──▶  batch of URLs
   (sitemap, ranking,                    │
    KV state)                            ▼
          ▲                       GitHub Actions
          │                              │
          │                              │ POST
          │                              ▼
          │                          IndexNow
          │                        (Bing first)
          │                              │
          └────commit(batch)─────────────┘
                                    on success only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow itself is unremarkable, which is the point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;17&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*'&lt;/span&gt;   &lt;span class="c1"&gt;# odd minute: GitHub queues heavily at :00&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;indexnow&lt;/span&gt;            &lt;span class="c1"&gt;# two runs would fetch the SAME batch&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;concurrency&lt;/code&gt; block is load-bearing. Since nothing is recorded until a submission is accepted, two overlapping runs would each ask for a plan, receive the identical batch, and submit it twice.&lt;/p&gt;

&lt;p&gt;First real run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"indexnow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"via"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"outcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"submitted"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"https://www.bing.com/indexnow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"submitted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"recorded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"listed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1906&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"due"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1006&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;&lt;code&gt;endpoint&lt;/code&gt; is the first one in the chain. Bing took it directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  A side observation: Yandex is fast, Bing is patient
&lt;/h2&gt;

&lt;p&gt;While all of this was still landing on Yandex, I pulled six hours of crawler traffic (1,000-event sample):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Crawler&lt;/th&gt;
&lt;th&gt;Hits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;YandexBot&lt;/td&gt;
&lt;td&gt;185&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PetalBot&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applebot&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bingbot&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;YandexBot went from background noise to dominant right after those submissions. IndexNow demonstrably causes crawling — Yandex just acts on a ping far more eagerly than Bing does.&lt;/p&gt;

&lt;p&gt;One clarification worth making, because I had it wrong myself at first: IndexNow shares the &lt;strong&gt;notification&lt;/strong&gt; between participants, not the crawl. A URL you submit to Yandex is advertised to Bing as well, but Bing still has to send bingbot and build its own index. "Yandex crawled it" never becomes "Bing indexed it".&lt;/p&gt;

&lt;p&gt;And if you're wondering whether any of this reaches Google: it doesn't. Google has been evaluating IndexNow since 2021 and still doesn't participate. Sitemaps and Search Console remain the only levers there.&lt;/p&gt;

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

&lt;p&gt;The final setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Cloudflare Worker owns URL selection and submission state.&lt;/li&gt;
&lt;li&gt;GitHub Actions owns the outbound POST.&lt;/li&gt;
&lt;li&gt;A failed submission is never recorded as sent, so the batch is safe to retry.&lt;/li&gt;
&lt;li&gt;"Nothing to submit" and "submission failed" are separate, named outcomes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part wasn't moving the request to GitHub Actions. It was separating &lt;strong&gt;policy&lt;/strong&gt; from &lt;strong&gt;transport&lt;/strong&gt; — and noticing that only the transport had a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A successful &lt;code&gt;fetch()&lt;/code&gt; is not the same thing as a successful submission.&lt;/li&gt;
&lt;li&gt;Log "nothing to do" and "request rejected" separately. Everything else here was invisible until that one change.&lt;/li&gt;
&lt;li&gt;When debugging a 429, send the identical request from a different network before assuming it's your rate.&lt;/li&gt;
&lt;li&gt;Keep selection and state out of the transport layer, so relocating the transport costs you one small script.&lt;/li&gt;
&lt;li&gt;Don't mark work as done until the receiving service says it accepted it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All the numbers above are from production logs on 2026-08-09. This is also part of why &lt;a href="https://aichangewatch.com/?src=devto" rel="noopener noreferrer"&gt;AI Change Watch&lt;/a&gt; is built around event-level change detection rather than periodic re-crawling: when you record &lt;em&gt;what changed and when&lt;/em&gt;, questions like "was this ever actually submitted?" have an answer you can look up.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>serverless</category>
      <category>cloudflare</category>
    </item>
  </channel>
</rss>
