<?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 Model Google Lists as Shut Down Answered Me Today. Read `modelVersion` Before You Trust It.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Mon, 21 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/a-model-google-lists-as-shut-down-answered-me-today-read-modelversion-before-you-trust-it-2bbf</link>
      <guid>https://dev.to/ai_changewatch/a-model-google-lists-as-shut-down-answered-me-today-read-modelversion-before-you-trust-it-2bbf</guid>
      <description>&lt;p&gt;Google's Gemini deprecation page defines its terms up front:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Once a model is "shutdown", it is completely turned off, and the endpoint is no longer&lt;br&gt;
available.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same page marks already-shutdown models with a grey row, and &lt;code&gt;gemini-3.1-flash-lite-preview&lt;/code&gt;&lt;br&gt;
is grey. Shut down. Endpoint gone.&lt;/p&gt;

&lt;p&gt;I called it on 2026-09-20:&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;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://generativelanguage.googleapis.com/v1beta/models/&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
gemini-3.1-flash-lite-preview:generateContent?key=&lt;/span&gt;&lt;span class="nv"&gt;$KEY&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;'{"contents":[{"parts":[{"text":"What is 7 times 6? Answer with the number only."}]}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 200
"42"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error, no warning header, no notice in the payload.&lt;/p&gt;

&lt;h2&gt;
  
  
  One field says what actually happened
&lt;/h2&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;"candidates"&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="err"&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;"modelVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemini-3.1-flash-lite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usageMetadata"&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;"totalTokenCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"serviceTier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standard"&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;I asked for &lt;code&gt;gemini-3.1-flash-lite-preview&lt;/code&gt;. I was served &lt;code&gt;gemini-3.1-flash-lite&lt;/code&gt; — the GA model&lt;br&gt;
named in the replacement column of the very row that says the preview is shut down.&lt;/p&gt;

&lt;p&gt;Calling the GA name directly confirms they are the same thing now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;request = gemini-3.1-flash-lite          -&amp;gt; modelVersion = gemini-3.1-flash-lite
request = gemini-3.1-flash-lite-preview  -&amp;gt; modelVersion = gemini-3.1-flash-lite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The retired ID resolves to its successor. Your call succeeds. A different model answers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you generalise this — two things that are NOT the story
&lt;/h2&gt;

&lt;p&gt;I went looking for "vendor ignores its own shutdown dates" and did not find it. Two corrections&lt;br&gt;
I had to make to my own reasoning, both from reading the page more carefully:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dates in that table are not shutdown dates.&lt;/strong&gt; The page says so explicitly:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The shutdown dates listed in the table indicate the earliest possible dates on which a model&lt;br&gt;
might be retired. We will communicate the exact shutdown date to users with advance notice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So a model still running past the date in its row is not a broken promise. It is the documented&lt;br&gt;
behaviour. Two other previews — &lt;code&gt;gemini-3.1-flash-image-preview&lt;/code&gt; and &lt;code&gt;gemini-3-pro-image-preview&lt;/code&gt;&lt;br&gt;
— are past their June 25 dates and still live, and their rows are &lt;em&gt;not&lt;/em&gt; grey. Google is not&lt;br&gt;
claiming they are shut down. Nothing is wrong there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scope is small.&lt;/strong&gt; Of the 32 rows the page marks as already shut down, exactly two still&lt;br&gt;
appear in the live &lt;code&gt;ListModels&lt;/code&gt; output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gemini-3.1-flash-lite-preview   -&amp;gt; gemini-3.1-flash-lite   (generation verified)
gemini-2.5-pro-preview-tts      -&amp;gt; countTokens returns 200 (generation not tested)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty of thirty-two are genuinely gone. This is a narrow gap, not a pattern of neglect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the narrow version still matters
&lt;/h2&gt;

&lt;p&gt;An alias is the kind choice. The alternative is a hard failure on the shutdown date, which takes&lt;br&gt;
down every service still pointing at the old name — including the ones whose owners never read&lt;br&gt;
the deprecation page. Google kept those callers running and pointed them at the model it had&lt;br&gt;
already told everyone to migrate to. If your code is "call an LLM, get text back," this saved&lt;br&gt;
you an outage and cost you nothing.&lt;/p&gt;

&lt;p&gt;But nobody types &lt;code&gt;-preview&lt;/code&gt; into a config file by accident. You pin a specific model ID for one&lt;br&gt;
reason: you want the same model next month that you had this month.&lt;/p&gt;

&lt;p&gt;That is the property the alias silently removes. Your request still succeeds, your tests still&lt;br&gt;
pass, your error rate stays flat — and the weights answering are not the weights you evaluated&lt;br&gt;
against. Prompts tuned on that preview, a golden-output suite, a published benchmark, a cached&lt;br&gt;
embedding space, a customer-facing behaviour you signed off on: all measured against a model&lt;br&gt;
that the vendor says is turned off.&lt;/p&gt;

&lt;p&gt;The failure mode is not an outage. It is quiet drift behind a green dashboard.&lt;/p&gt;
&lt;h2&gt;
  
  
  Nobody has said how long this lasts
&lt;/h2&gt;

&lt;p&gt;Here is the part I want to be clearest about, because it is the part that should change what you&lt;br&gt;
do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The aliasing is not documented anywhere I can find.&lt;/strong&gt; The deprecation page does not mention&lt;br&gt;
aliases, redirects, or fallbacks — the words do not appear on it. It says the opposite: that a&lt;br&gt;
shut-down model's endpoint is no longer available. There is no stated policy for how long a&lt;br&gt;
retired ID keeps resolving, no commitment that it will keep working tomorrow, and no announcement&lt;br&gt;
channel for the day it stops.&lt;/p&gt;

&lt;p&gt;Which means you cannot plan against it. The behaviour I measured today is not a feature you were&lt;br&gt;
offered and it is not a guarantee you can hold anyone to. It is an undocumented grace period of&lt;br&gt;
unknown length, and the day it ends, the failure will look exactly like the outage the alias was&lt;br&gt;
preventing — except now it arrives with no date attached, because the date already passed months&lt;br&gt;
ago and you stopped watching.&lt;/p&gt;

&lt;p&gt;Treat it as time to migrate, not as a migration you can skip.&lt;/p&gt;
&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Log &lt;code&gt;modelVersion&lt;/code&gt; on every call.&lt;/strong&gt; One field, and the only thing in the response that tells&lt;br&gt;
you what answered. If you store nothing else about a request, store this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert on it where it matters.&lt;/strong&gt; A suite that exists to prove your prompt still behaves should&lt;br&gt;
fail when the model underneath changes, not when the output finally drifts far enough for a human&lt;br&gt;
to notice:&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;res&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;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&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;modelVersion&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;EXPECTED_MODEL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`served by &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;modelVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, pinned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;EXPECTED_MODEL&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Read a retirement row as "the answer may change", not "the call may fail".&lt;/strong&gt; Those are&lt;br&gt;
different events, and only one of them pages you.&lt;/p&gt;

&lt;p&gt;Most LLM plumbing I see checks the status code and reads &lt;code&gt;candidates[0]&lt;/code&gt;. Everything between&lt;br&gt;
those two is treated as noise. Here the entire signal was in the noise.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Verified against the live pages and API on 2026-09-20. I keep a record of these dates and what&lt;br&gt;
they said before they changed:&lt;br&gt;
&lt;a href="https://aichangewatch.com/deprecations?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com/deprecations&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>api</category>
      <category>devops</category>
    </item>
    <item>
      <title>My CLS Was 0 Until I Throttled the Network. Then It Was 0.11.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Fri, 18 Sep 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/my-cls-was-0-until-i-throttled-the-network-then-it-was-011-1cee</link>
      <guid>https://dev.to/ai_changewatch/my-cls-was-0-until-i-throttled-the-network-then-it-was-011-1cee</guid>
      <description>&lt;p&gt;Every measurement I took said my Cumulative Layout Shift was 0. Lighthouse, a headless Chrome&lt;br&gt;
run, reloading the page and staring at it — all zero. The pages were shifting anyway.&lt;/p&gt;

&lt;p&gt;The number was wrong because of how I was loading the page, and the shift itself came from a CSS&lt;br&gt;
selector I would have called harmless.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why an unthrottled measurement reads zero
&lt;/h2&gt;

&lt;p&gt;Layout shift needs two paints to happen. The browser lays something out, more bytes arrive, and&lt;br&gt;
it lays it out differently.&lt;/p&gt;

&lt;p&gt;On a fast connection to a nearby server, the entire HTML document lands in one chunk. There is no&lt;br&gt;
second paint, so there is no shift to record — on a page that genuinely shifts for real users on&lt;br&gt;
real connections. The measurement isn't noisy. It is confidently, reproducibly zero.&lt;/p&gt;

&lt;p&gt;You have to slow the page down until the streaming actually streams.&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;// Drive Chrome over CDP: --headless=new --remote-debugging-port=9222&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Emulation.setCPUThrottlingRate&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;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&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;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Network.emulateNetworkConditions&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;offline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;downloadThroughput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;uploadThroughput&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&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;And record the shifts with the sources attached, because a bare CLS number tells you nothing&lt;br&gt;
about which element moved:&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;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Page.addScriptToEvaluateOnNewDocument&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;`
    window.__shifts = [];
    new PerformanceObserver((list) =&amp;gt; {
      for (const entry of list.getEntries()) {
        if (entry.hadRecentInput) continue;
        window.__shifts.push({
          value: entry.value,
          sources: entry.sources.map((s) =&amp;gt; ({
            node: s.node &amp;amp;&amp;amp; s.node.nodeName + '.' + (s.node.className || ''),
            from: s.previousRect,
            to: s.currentRect,
          })),
        });
      }
    }).observe({ type: 'layout-shift', buffered: true });
  `&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;Cache-bust the URL on every run. A warm CDN edge undoes the throttling you just configured.&lt;/p&gt;

&lt;p&gt;Throttled, the same pages that had measured 0 came back at &lt;strong&gt;0.110&lt;/strong&gt; and &lt;strong&gt;0.025&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The selector
&lt;/h2&gt;

&lt;p&gt;The layout is a content column with an optional sidebar. Some pages have the sidebar, some don't,&lt;br&gt;
and the shell was deciding for itself which case it was in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* Two columns, unless there is no rail inside me. */&lt;/span&gt;
&lt;span class="nc"&gt;.shell&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;274px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.shell&lt;/span&gt;&lt;span class="nd"&gt;:not&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nd"&gt;:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.termrail&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That reads as elegant. The container asks a question about its own contents and answers it. No&lt;br&gt;
prop drilling, no flag to keep in sync, no way for the class and the markup to disagree.&lt;/p&gt;

&lt;p&gt;It is also a bet — that the child parses before first paint.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where that bet loses
&lt;/h2&gt;

&lt;p&gt;The rail is the shell's last child. Here is what that means on a real page of mine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTML document        407,197 characters
.shell opens at       11,722
.termrail appears at 134,567   ← 122,845 characters later
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser sees &lt;code&gt;.shell&lt;/code&gt; open, looks inside for &lt;code&gt;.termrail&lt;/code&gt;, does not find it yet, and paints&lt;br&gt;
the content column at full width. 122,845 characters later the rail arrives, &lt;code&gt;:has()&lt;/code&gt; starts&lt;br&gt;
matching, and the column is re-laid out 274px narrower.&lt;/p&gt;

&lt;p&gt;Everything the user had already started reading moves left. That is the 0.110.&lt;/p&gt;

&lt;p&gt;The bet wins on a short page — the whole thing arrives before first paint and &lt;code&gt;:has()&lt;/code&gt; is right&lt;br&gt;
the first time. It loses in exact proportion to how much HTML you ship, which means it fails worst&lt;br&gt;
on your biggest, most content-heavy pages. Those are usually the ones that matter.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix is to move the decision earlier
&lt;/h2&gt;

&lt;p&gt;The information was available on the server the whole time. The only reason it wasn't in the&lt;br&gt;
markup is that &lt;code&gt;:has()&lt;/code&gt; made it feel unnecessary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The server already knows whether this page has a rail.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;keys&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shell has-rail&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;shell&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;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 css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.shell&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="m"&gt;274px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.shell&lt;/span&gt;&lt;span class="nd"&gt;:not&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.has-rail&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;grid-template-columns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;minmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="n"&gt;fr&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 class now rides the container's opening tag. On that page it lands 122,845 characters ahead&lt;br&gt;
of the element it used to depend on. Re-measured under the same throttling: &lt;strong&gt;0&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  The rule I took from it
&lt;/h2&gt;

&lt;p&gt;Not "avoid &lt;code&gt;:has()&lt;/code&gt;". The rule is narrower:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Never gate a grid or flex track count on &lt;code&gt;:has(&amp;lt;descendant&amp;gt;)&lt;/code&gt;. Put the signal on the container's&lt;br&gt;
opening tag.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Anything that changes how much space siblings get — column counts, track sizes, &lt;code&gt;flex-basis&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;display&lt;/code&gt; on a wrapper — is a layout decision, and a layout decision keyed off a later element is&lt;br&gt;
a race you lose on long pages.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;:has()&lt;/code&gt; is fine where being wrong for a moment costs nothing. These are still in my stylesheet&lt;br&gt;
and I have no plans to remove them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nd"&gt;:root:has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;.gl-page&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;scroll-behavior&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;smooth&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;Nothing reflows if that flips halfway through the load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things worth keeping
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A performance number you measured without throttling is not a measurement.&lt;/strong&gt; It is a statement&lt;br&gt;
about your connection. The default in headless Chrome is your own network and CPU, and both are&lt;br&gt;
faster than what your users have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch &lt;code&gt;entry.sources&lt;/code&gt;, not the score.&lt;/strong&gt; The total told me there was a problem, once I could see&lt;br&gt;
one at all. It was the previous and current rects on the sources array — a column that was 1180px&lt;br&gt;
wide and became 906px — that named the element and pointed straight at the selector.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of a project that crawls the docs of 15 AI vendors and records every change with&lt;br&gt;
the date it happened: &lt;a href="https://aichangewatch.com?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>css</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Search Console Shows Your Canonical Tag. Googlebot Never Gets It.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 15 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/search-console-shows-your-canonical-tag-googlebot-never-gets-it-256k</link>
      <guid>https://dev.to/ai_changewatch/search-console-shows-your-canonical-tag-googlebot-never-gets-it-256k</guid>
      <description>&lt;p&gt;&lt;code&gt;grep&lt;/code&gt; found my &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt;. Search Console's URL Inspection showed it. It was still&lt;br&gt;
landing outside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; for the crawler that actually indexes the site.&lt;/p&gt;

&lt;p&gt;Here is the check that finally showed it, on a dynamic route in Next.js 15.5:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#    HTML bytes   &amp;lt;/head&amp;gt; at   canonical at
1       151,428        4,766         103,412
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tag exists. It is 98,000 characters past the end of the head.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;grep&lt;/code&gt; can't see this
&lt;/h2&gt;

&lt;p&gt;Every tool I reached for first answers a different question than the one that matters.&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;-s&lt;/span&gt; https://example.com/some-page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'rel="canonical"'&lt;/span&gt;
&lt;span class="c"&gt;# 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;1&lt;/code&gt; is true and useless. Google ignores &lt;code&gt;canonical&lt;/code&gt; and &lt;code&gt;hreflang&lt;/code&gt; found outside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;&lt;br&gt;
in the raw HTML. React moves the tag into the head on the client, so a browser never shows you&lt;br&gt;
anything wrong either — open DevTools and it is sitting in &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;, exactly where you put it.&lt;/p&gt;

&lt;p&gt;The question is not whether the tag is present. It is &lt;strong&gt;whether it appears before &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt;&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;UA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://example.com/some-page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;UA&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="n"&gt;html&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ignore&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canon&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;html&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/head&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;html&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;rel=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;canonical&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;head&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inside&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;canon&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OUTSIDE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The cause is a design decision, not a bug
&lt;/h2&gt;

&lt;p&gt;Next 15.2+ streams metadata by default. The shell flushes as soon as the page body is ready, and&lt;br&gt;
whatever &lt;code&gt;generateMetadata&lt;/code&gt; returns is injected later — after &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt; has already gone out on&lt;br&gt;
the wire.&lt;/p&gt;

&lt;p&gt;Next knows some clients cannot cope with that, so it keeps an exception list of user agents that&lt;br&gt;
get a blocking render instead. It lives in &lt;code&gt;next/dist/shared/lib/router/utils/html-bots.js&lt;/code&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;HTML_LIMITED_BOT_UA_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+-Google|Google-&lt;/span&gt;&lt;span class="se"&gt;[\w&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the two Google patterns closely. &lt;code&gt;[\w-]+-Google&lt;/code&gt; matches anything ending in &lt;code&gt;-Google&lt;/code&gt;.&lt;br&gt;
&lt;code&gt;Google-[\w-]+&lt;/code&gt; matches anything starting with &lt;code&gt;Google-&lt;/code&gt;. Neither one matches the string&lt;br&gt;
&lt;code&gt;Googlebot&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Run it yourself:&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;HTML_LIMITED_BOT_UA_RE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/dist/shared/lib/router/utils/html-bots.js&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;const&lt;/span&gt; &lt;span class="nx"&gt;ua&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Googlebot&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;AdsBot-Google&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;Mediapartners-Google&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;Google-InspectionTool&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;Bingbot&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;Applebot&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&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="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ua&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;blocking &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;streaming&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ua&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;Output on &lt;code&gt;next@15.5.21&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;streaming Googlebot
blocking  AdsBot-Google
blocking  Mediapartners-Google
blocking  Google-InspectionTool
blocking  Bingbot
blocking  Applebot
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is deliberate. The reasoning is that Googlebot executes JavaScript, so it will see the tag&lt;br&gt;
after React relocates it — but only if the URL reaches the rendering queue, which is a second&lt;br&gt;
pass and not guaranteed. The ad crawlers and the link-preview bots, which genuinely only read raw&lt;br&gt;
HTML, are on the list.&lt;/p&gt;
&lt;h2&gt;
  
  
  The part that cost me the most time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Google-InspectionTool&lt;/code&gt; is the user agent behind &lt;strong&gt;URL Inspection in Search Console&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;It matches &lt;code&gt;Google-[\w-]+&lt;/code&gt;. It gets a blocking render. So the one tool you would naturally reach&lt;br&gt;
for to verify the problem is served a complete, correct document — while plain &lt;code&gt;Googlebot&lt;/code&gt;, the&lt;br&gt;
thing that actually crawls you, is served the streamed one.&lt;/p&gt;

&lt;p&gt;Bingbot is on the list too, so Bing Webmaster Tools looks healthy as well. Two independent&lt;br&gt;
consoles agreeing that everything is fine is not confirmation here. They are both talking to the&lt;br&gt;
blocking path.&lt;/p&gt;
&lt;h2&gt;
  
  
  It only happens sometimes, and only on some routes
&lt;/h2&gt;

&lt;p&gt;This is what kept it hidden. Two conditions have to line up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is timing-dependent.&lt;/strong&gt; Fetch a URL once, on its own, and the metadata almost always lands&lt;br&gt;
inside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; — the response is fast enough that the injection wins the race. It surfaces under&lt;br&gt;
back-to-back load, which is what a crawler actually does to you. Measured across 116&lt;br&gt;
&lt;code&gt;/{provider}/{category}&lt;/code&gt; URLs fetched in sequence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User agent&lt;/th&gt;
&lt;th&gt;canonical outside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;
&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;21 / 116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Googlebot&lt;/td&gt;
&lt;td&gt;16 / 116&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bingbot&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 / 116&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bingbot's zero is the control: same URLs, same moment, blocking render, never broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is dynamic routes only.&lt;/strong&gt; Anything served from ISR replays a complete, already-assembled&lt;br&gt;
document, so the streaming race never happens. The &lt;code&gt;x-nextjs-cache&lt;/code&gt; response header tells you&lt;br&gt;
which kind of route you are looking at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/some-hub | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; x-nextjs-cache
&lt;span class="go"&gt;x-nextjs-cache: STALE      &amp;lt;- replayed from cache, safe

&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://example.com/some-dynamic-page | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; x-nextjs-cache
&lt;span class="go"&gt;                           &amp;lt;- no header, rendered per request, at risk
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If everything you serve is statically generated or ISR-cached, none of this applies to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;htmlLimitedBots&lt;/code&gt; in &lt;code&gt;next.config.mjs&lt;/code&gt; lets you supply your own regex. One catch worth knowing:&lt;br&gt;
it &lt;strong&gt;replaces&lt;/strong&gt; Next's list rather than extending it. &lt;code&gt;base-server.js&lt;/code&gt; reads&lt;br&gt;
&lt;code&gt;config.htmlLimitedBots || HTML_LIMITED_BOT_UA_RE_STRING&lt;/code&gt;, so writing &lt;code&gt;/Googlebot/i&lt;/code&gt; there would&lt;br&gt;
silently drop Bingbot, Applebot, Twitterbot and everything else.&lt;/p&gt;

&lt;p&gt;Build it from Next's own source, so the list cannot go stale underneath you:&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createRequire&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;node:module&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;require_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createRequire&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;w-]+-Google|Google-[&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s1"&gt;w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot&lt;/span&gt;&lt;span class="dl"&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;nextBots&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;nextBots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/dist/shared/lib/router/utils/html-bots.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;HTML_LIMITED_BOT_UA_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;nextBots&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FALLBACK&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// only if that internal path moves&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="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;htmlLimitedBots&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;RegExp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Googlebot|&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nextBots&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;i&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;After deploying, the same back-to-back measurement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;canonical outside &amp;lt;head&amp;gt;, Googlebot UA:  0 / 116
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-checked ten days later on a dynamic route, ten parallel requests: &lt;code&gt;&amp;lt;/head&amp;gt;&lt;/code&gt; at 4,766,&lt;br&gt;
canonical at 2,248, every single time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blocking costs nothing at this size.&lt;/strong&gt; Median response with a Googlebot UA was 144–317ms.&lt;br&gt;
Bingbot — already blocking, on the same URLs — was 129–222ms. Browsers keep streaming, because&lt;br&gt;
Chrome does not match the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is not
&lt;/h2&gt;

&lt;p&gt;It is an indexing signal, not a reachability one. Canonical outside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; never stopped a&lt;br&gt;
single crawl, and it explains nothing about crawl volume, coverage, or a page missing from the&lt;br&gt;
index entirely. I checked whether it explained my own indexing gap before believing that it did,&lt;br&gt;
and it did not — the pages I most wanted indexed were ISR-cached, so their canonical had been in&lt;br&gt;
&lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; the whole time.&lt;/p&gt;

&lt;p&gt;What it does mean is narrower, and still worth fixing: on dynamic routes, some fraction of&lt;br&gt;
Googlebot's fetches were told nothing about which URL is canonical or which language alternates&lt;br&gt;
exist. On a site with one URL per thing, that fraction is a rounding error. On a site with ten&lt;br&gt;
locales pointing at the same content, it is the entire signal.&lt;/p&gt;

&lt;p&gt;The general lesson is one I keep relearning. When you verify a crawler-facing behaviour, send the&lt;br&gt;
crawler's user agent, and check the thing's &lt;strong&gt;position&lt;/strong&gt;, not its presence.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This came out of a project that crawls the docs of 15 AI vendors and records every change with&lt;br&gt;
the date it happened: &lt;a href="https://aichangewatch.com?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>react</category>
    </item>
    <item>
      <title>My "Last Hour" Query Returned 1,252 Rows. The Real Answer Was 68.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Fri, 11 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/my-last-hour-query-returned-1252-rows-the-real-answer-was-68-5fgh</link>
      <guid>https://dev.to/ai_changewatch/my-last-hour-query-returned-1252-rows-the-real-answer-was-68-5fgh</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;Every so often I check whether the crawler is still alive by counting recent runs. The query is the&lt;br&gt;
obvious one:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;crawl_runs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-1 hour'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returned &lt;strong&gt;1,252&lt;/strong&gt;. The true number of runs in that hour was &lt;strong&gt;68&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;No error. No warning. A plausible-looking integer, roughly eighteen times too large, from a query that&lt;br&gt;
reads correctly in review.&lt;/p&gt;
&lt;h2&gt;
  
  
  The two halves of the comparison are different formats
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;crawl_runs.started_at&lt;/code&gt; is written by application code, as ISO 8601:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-08-24T17:40:41.965Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQLite's &lt;code&gt;datetime()&lt;/code&gt; returns its own format, which uses a &lt;strong&gt;space&lt;/strong&gt; instead of &lt;code&gt;T&lt;/code&gt; and has no&lt;br&gt;
fractional part or zone suffix:&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="n"&gt;sqlite&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-1 hour'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;54&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;52&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are strings. SQLite has no dedicated date type — dates are TEXT, REAL or INTEGER by convention —&lt;br&gt;
so &lt;code&gt;&amp;gt;&lt;/code&gt; here is a &lt;strong&gt;text comparison&lt;/strong&gt;, byte by byte.&lt;/p&gt;

&lt;p&gt;And that is where it goes wrong, at exactly one character:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;position 11&lt;/th&gt;
&lt;th&gt;value&lt;/th&gt;
&lt;th&gt;byte&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;stored&lt;/td&gt;
&lt;td&gt;&lt;code&gt;T&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x54&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cutoff&lt;/td&gt;
&lt;td&gt;&lt;code&gt;' '&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x20&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;T&lt;/code&gt; sorts above a space. So for every row whose &lt;strong&gt;date part is the same day as the cutoff&lt;/strong&gt;, the&lt;br&gt;
comparison stops at position 11, finds &lt;code&gt;0x54 &amp;gt; 0x20&lt;/code&gt;, and answers &lt;em&gt;greater&lt;/em&gt; — no matter what time it&lt;br&gt;
actually is. A run from 00:03 that morning is "in the last hour."&lt;/p&gt;

&lt;p&gt;The fix is to make the cutoff the same shape as the column:&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="c1"&gt;-- wrong: 1252 rows&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-1 hour'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;-- right: 68 rows&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%Y-%m-%dT%H:%M:%SZ'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-1 hour'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it survived so long
&lt;/h2&gt;

&lt;p&gt;Because both of its failure modes are comfortable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For freshness checks it fails loud.&lt;/strong&gt; The number comes out too big, which means a stalled crawler&lt;br&gt;
still looks busy. This is the dangerous direction — it is precisely the check whose whole job is to tell&lt;br&gt;
you something stopped, and it is biased toward saying everything is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For windowed audits it fails safe.&lt;/strong&gt; &lt;code&gt;-1 day&lt;/code&gt; or &lt;code&gt;-7 day&lt;/code&gt; pulls in the entire boundary day, so a&lt;br&gt;
review window is wider than you stated, never narrower. Nothing is missed; you just quietly reviewed&lt;br&gt;
more than you meant to. Nobody notices being handed extra.&lt;/p&gt;

&lt;p&gt;Neither shows up in the output. You do not get a type error, a coercion warning, or an empty result that&lt;br&gt;
makes you look twice. You get rows, and they are real rows, and they are formatted like the ones you&lt;br&gt;
wanted.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where this bug actually lives
&lt;/h2&gt;

&lt;p&gt;Here is the part I found most interesting once I went looking. &lt;strong&gt;My shipped code was never affected.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nothing under &lt;code&gt;src/&lt;/code&gt; or &lt;code&gt;web/&lt;/code&gt; calls SQLite's &lt;code&gt;datetime('now')&lt;/code&gt; or &lt;code&gt;julianday('now')&lt;/code&gt; at all. Every&lt;br&gt;
bound in the application is built in JavaScript:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;since&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;Date&lt;/span&gt;&lt;span class="p"&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="mi"&gt;86&lt;/span&gt;&lt;span class="nx"&gt;_400_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// '2026-08-23T17:40:41.965Z'  — same shape as the column&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which emits the identical &lt;code&gt;T&lt;/code&gt;/&lt;code&gt;Z&lt;/code&gt; form, so the comparison is like-for-like everywhere it ships.&lt;/p&gt;

&lt;p&gt;The bug lived entirely in &lt;strong&gt;hand-written operational queries&lt;/strong&gt; — the ones I type into a console to&lt;br&gt;
answer a question right now. That is the least-examined code in any project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no test covers it&lt;/li&gt;
&lt;li&gt;no reviewer reads it&lt;/li&gt;
&lt;li&gt;it exists for ninety seconds and produces a number you then repeat to someone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Several "runs in the last hour" figures I had quoted before finding this were inflated by it. The&lt;br&gt;
application was healthy the whole time; the instrument was wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  If you can't change the format on both sides
&lt;/h2&gt;

&lt;p&gt;Two escape hatches, in the order I'd reach for them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalise the cutoff, not the column.&lt;/strong&gt; Rewriting stored data is a migration; rewriting a cutoff is a&lt;br&gt;
line. &lt;code&gt;strftime&lt;/code&gt; above is the clean version, but if you already have &lt;code&gt;datetime()&lt;/code&gt; calls scattered through&lt;br&gt;
a script, patching them in place also works:&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-1 hour'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'T'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'Z'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I prefer &lt;code&gt;strftime&lt;/code&gt; — it states the format it produces instead of repairing one — but this is fine when&lt;br&gt;
you are editing twenty ad-hoc queries and want a mechanical change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or stop storing dates as text.&lt;/strong&gt; SQLite has no date type; the documentation offers three conventions —&lt;br&gt;
ISO-8601 TEXT, Julian day as REAL, and Unix epoch as INTEGER — and the whole class of bug in this post&lt;br&gt;
only exists in the first one. Integers compare as numbers, so there is no format to disagree about:&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;started_at_ms&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unixepoch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'now'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-1 hour'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade is readability. &lt;code&gt;1787143241965&lt;/code&gt; in a console tells you nothing, and every query you write by&lt;br&gt;
hand now needs a conversion to be legible. For a table I mostly read by eye I kept the text column and&lt;br&gt;
fixed the cutoffs. For one I only ever compared, I would not.&lt;/p&gt;

&lt;p&gt;Worth knowing which trade you made, rather than discovering it at position 11 of a string.&lt;/p&gt;
&lt;h2&gt;
  
  
  How to find this in your own code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Grep for the mixed forms.&lt;/strong&gt; If both of these return hits in the same project, you have the ingredients:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rg &lt;span class="s2"&gt;"datetime&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;'now'|julianday&lt;/span&gt;&lt;span class="se"&gt;\(&lt;/span&gt;&lt;span class="s2"&gt;'now'"&lt;/span&gt;     &lt;span class="c"&gt;# SQL-side clocks&lt;/span&gt;
rg &lt;span class="s2"&gt;"toISOString&lt;/span&gt;&lt;span class="se"&gt;\(\)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;                      &lt;span class="c"&gt;# JS-side timestamps&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug is not either one. It's a comparison with one of each on opposite sides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-check any window with a grouping.&lt;/strong&gt; This is the cheap, general test, and it needs no knowledge of&lt;br&gt;
the storage format:&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="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;substr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;COUNT&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;crawl_runs&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the hourly buckets don't sum to what your windowed query claimed, the comparison is the reason. That&lt;br&gt;
is how I found the 68.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Look at your columns before you compare them.&lt;/strong&gt; One &lt;code&gt;SELECT started_at FROM crawl_runs LIMIT 1&lt;/code&gt; would&lt;br&gt;
have shown me the &lt;code&gt;T&lt;/code&gt; at any point in the preceding months.&lt;/p&gt;

&lt;p&gt;The general form of this, which is not really about SQLite: &lt;strong&gt;a comparison between two values that were&lt;br&gt;
produced by different systems is a format assumption, whether or not you wrote it down.&lt;/strong&gt; Text&lt;br&gt;
comparison will not tell you when the assumption is wrong. It will answer the question you literally&lt;br&gt;
asked, in a shape indistinguishable from the answer you wanted.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found 2026-08-24, 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 records what AI vendors change in their&lt;br&gt;
own docs, which involves a lot of timestamps that have to be comparable across sources.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>sql</category>
      <category>sqlite</category>
    </item>
    <item>
      <title>"Application error: a client-side exception has occurred" Is a Stale Chunk 404</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 08 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/application-error-a-client-side-exception-has-occurred-is-a-stale-chunk-404-28nk</link>
      <guid>https://dev.to/ai_changewatch/application-error-a-client-side-exception-has-occurred-is-a-stale-chunk-404-28nk</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. It's Next.js App Router on Cloudflare&lt;br&gt;
Workers via OpenNext.&lt;/p&gt;

&lt;p&gt;For a while it would, occasionally, break in the browser after I shipped something. Not on load — on the&lt;br&gt;
next click.&lt;/p&gt;
&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;You have the site open. You navigate — Home, a provider page, anything with a client transition. Instead&lt;br&gt;
of the page you get a white screen and one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application error: a client-side exception has occurred
(see the browser console for more information)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reload and it's gone. Everything works. You can't reproduce it on demand.&lt;/p&gt;

&lt;p&gt;Two properties make this genuinely hard to place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's intermittent.&lt;/strong&gt; It hits some tabs and not others, some navigations and not others.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The message is deliberately empty.&lt;/strong&gt; That string is React's production error boundary refusing to
leak details. It is the same message for every uncaught render error, so it tells you nothing about
which one you have.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The trap: the server is fine, and that misleads you
&lt;/h2&gt;

&lt;p&gt;The first thing I did was look at the server. All of it was clean:&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;wrangler &lt;span class="nb"&gt;tail &lt;/span&gt;changewatch-web
... &lt;span class="s2"&gt;"exceptions"&lt;/span&gt;: &lt;span class="o"&gt;[]&lt;/span&gt; ... &lt;span class="s2"&gt;"exceptions"&lt;/span&gt;: &lt;span class="o"&gt;[]&lt;/span&gt; ... &lt;span class="s2"&gt;"exceptions"&lt;/span&gt;: &lt;span class="o"&gt;[]&lt;/span&gt; ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request. And Home returned &lt;code&gt;200&lt;/code&gt; across every filter, every provider, every page size I tried. No&lt;br&gt;
&lt;code&gt;500&lt;/code&gt;, no thrown exception, no failed render.&lt;/p&gt;

&lt;p&gt;That reads as &lt;em&gt;"the server is healthy, so the bug is in my component code"&lt;/em&gt; — and it sends you into the&lt;br&gt;
one place the problem is not. It took me longer than it should have to state the actual reason the logs&lt;br&gt;
were empty:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Worker logs never contain client exceptions.&lt;/strong&gt; The error happened in the browser, after the response&lt;br&gt;
was delivered and logged as a success. A clean server log is not evidence about the client. It's silence&lt;br&gt;
about the client.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one request that names it
&lt;/h2&gt;

&lt;p&gt;Open DevTools, keep the Network tab open, and reproduce the navigation. You are looking for a &lt;strong&gt;404 on a&lt;br&gt;
&lt;code&gt;/_next/static/...&lt;/code&gt; path&lt;/strong&gt;:&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 /_next/static/chunks/app/[locale]/layout-4f2c9a1b8e.js   404
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it's there, you are done diagnosing. That is deployment skew, and the underlying error is a&lt;br&gt;
&lt;code&gt;ChunkLoadError&lt;/code&gt; — React caught it, and the boundary printed the generic line.&lt;/p&gt;

&lt;p&gt;You can confirm it in the Console, which will have the real message that the page refused to show you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ChunkLoadError: Loading chunk app/[locale]/layout failed.
(error: https://.../_next/static/chunks/app/[locale]/layout-4f2c9a1b8e.js)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why a redeploy does this
&lt;/h2&gt;

&lt;p&gt;Next.js splits your app into hashed chunks and loads them on demand. A page you open is not the whole&lt;br&gt;
app — it's an HTML document plus the chunks needed &lt;em&gt;so far&lt;/em&gt;. The chunks for a route you haven't visited&lt;br&gt;
yet are fetched at the moment you navigate there.&lt;/p&gt;

&lt;p&gt;The hash in a chunk's filename is derived from its contents. Change &lt;code&gt;layout.tsx&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;layout-4f2c9a1b8e.js&lt;/code&gt; becomes &lt;code&gt;layout-91d0c73aa2.js&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now put those two facts together with how static assets are hosted. On Workers Assets — and on most&lt;br&gt;
static hosting, this is not Cloudflare-specific — &lt;strong&gt;only the current build's assets exist.&lt;/strong&gt; Deploying&lt;br&gt;
replaces the set. The old hashed filenames stop resolving.&lt;/p&gt;

&lt;p&gt;So the sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A browser loads the site on build &lt;strong&gt;A&lt;/strong&gt;. The document and the initial chunks are A's.&lt;/li&gt;
&lt;li&gt;You deploy build &lt;strong&gt;B&lt;/strong&gt;. A's chunk files are gone.&lt;/li&gt;
&lt;li&gt;That still-open tab navigates. It requests the chunk name it learned from A.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;404&lt;/code&gt;. &lt;code&gt;ChunkLoadError&lt;/code&gt;. Generic error boundary.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Which explains both properties from the top. &lt;strong&gt;Intermittent&lt;/strong&gt;, because it only affects tabs that were&lt;br&gt;
loaded before the deploy and then navigated after it. &lt;strong&gt;Fixed by reload&lt;/strong&gt;, because a reload fetches the&lt;br&gt;
new document, which references B's chunk names.&lt;/p&gt;

&lt;p&gt;It also explains why this feels like it's getting worse when you're actively working: during a session&lt;br&gt;
where you deploy six times in an hour, every tab you left open is a candidate. At a normal release&lt;br&gt;
cadence it only touches people who happened to have the site open across the deploy.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two fixes, and they are not equivalent
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Recover automatically.&lt;/strong&gt; Add an error boundary that recognises this specific failure and reloads&lt;br&gt;
instead of rendering the message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/[locale]/error.tsx&lt;/span&gt;
&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Error&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;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ChunkLoadError&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reload&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Something went wrong.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the honest fix for a user-facing site: the reload is what the visitor would have done anyway, so&lt;br&gt;
do it for them. The cost is that you have taken a class of error and made it invisible — if something&lt;br&gt;
else ever produces a &lt;code&gt;ChunkLoadError&lt;/code&gt;, you now have a silent reload loop instead of a report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep old builds around.&lt;/strong&gt; Some platforms let you retain previous deployments' static assets so old&lt;br&gt;
chunk names keep resolving for a while. This removes the 404 rather than reacting to it, and it is&lt;br&gt;
strictly better where it's available. It was not an option for me.&lt;/p&gt;

&lt;p&gt;I went with neither, deliberately. My traffic is small, my deploys are frequent, and the failure costs a&lt;br&gt;
reload — so I recorded what it was, in writing, and stopped investigating it. &lt;strong&gt;The expensive part of&lt;br&gt;
this bug was never the impact. It was the three separate times I opened the wrong file looking for it.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;The thing I'd actually want to keep from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A generic error message plus a clean server log is a specific combination, not an absence of&lt;br&gt;
information.&lt;/strong&gt; It means the failure is downstream of your logs — in the browser, in the CDN, in the&lt;br&gt;
network — and it should send you to a different tool, not to a closer reading of your components. I kept&lt;br&gt;
re-reading the server side because that was the side I could see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hashed filename is a contract with a build that may no longer exist.&lt;/strong&gt; Anything that caches, retries,&lt;br&gt;
prefetches or holds a reference across a deploy is exposed to it. Long-lived tabs are just the most&lt;br&gt;
common case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reproduce it before you fix it.&lt;/strong&gt; You can trigger this on demand: open the site, deploy, then click a&lt;br&gt;
link in the old tab without reloading. If it doesn't reproduce, you have a different problem, and the&lt;br&gt;
error boundary above would have hidden it from you.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>`No test suite found in file` on All 71 Files: Your `vite` Override Has No Upper Bound</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Fri, 04 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/no-test-suite-found-in-file-on-all-71-files-your-vite-override-has-no-upper-bound-na5</link>
      <guid>https://dev.to/ai_changewatch/no-test-suite-found-in-file-on-all-71-files-your-vite-override-has-no-upper-bound-na5</guid>
      <description>&lt;p&gt;&lt;code&gt;pnpm test&lt;/code&gt; had been ending like this for a while:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt; ❯ test/analyzeChange.test.ts  (0 test)
 ❯ test/rowMapper.test.ts      (0 test)
 ... 69 more ...

 Test Files  no tests
      Tests  no tests
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of the 71 files printed &lt;code&gt;No test suite found in file&lt;/code&gt;, and the run exited non-zero.&lt;/p&gt;

&lt;p&gt;I read that as a config problem. It looks exactly like one: a bad &lt;code&gt;include&lt;/code&gt; glob, a &lt;code&gt;projects&lt;/code&gt; entry&lt;br&gt;
pointing at the wrong directory, a &lt;code&gt;environment&lt;/code&gt; mismatch — the kind of thing where the runner is fine&lt;br&gt;
and your setup is wrong. So it sat there. Meanwhile the actual state was that &lt;strong&gt;845 tests had not&lt;br&gt;
executed once in that window&lt;/strong&gt;, and nothing on the screen said so in those words.&lt;/p&gt;

&lt;p&gt;The cause was a line I had written myself, for a good reason, four months earlier.&lt;/p&gt;
&lt;h2&gt;
  
  
  A security override with no upper bound
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;:&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;overrides&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Security patch: dev-only vite advisories (fs.deny bypass / path traversal / launch-editor).&lt;/span&gt;
  &lt;span class="c1"&gt;# Force the patched vite that vitest transitively pulls. Not shipped to production.&lt;/span&gt;
  &lt;span class="na"&gt;vite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;=6.4.3"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I don't depend on vite directly. It arrives transitively under vitest. A batch of dev-only advisories&lt;br&gt;
came out, the patched version was 6.4.3, and an override was the shortest way to guarantee every copy in&lt;br&gt;
the tree was at or above it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;gt;=6.4.3&lt;/code&gt; is not a pin. It is an open-ended range, and an override outranks whatever the dependent&lt;br&gt;
declares. So when vite 8.1.4 shipped, pnpm resolved it — even though &lt;strong&gt;vitest 3.2.7 declares&lt;br&gt;
&lt;code&gt;^5 || ^6 || ^7.0.0-0&lt;/code&gt;&lt;/strong&gt;, which does not include 8.&lt;/p&gt;

&lt;p&gt;That is the part worth internalising: the peer range vitest publishes is exactly the guard that would&lt;br&gt;
have stopped this, and &lt;code&gt;overrides&lt;/code&gt; is the mechanism that tells the resolver to ignore it. I had opted&lt;br&gt;
out of the safety check and left a note explaining why, and the note said nothing about the ceiling&lt;br&gt;
because at the time there was nothing above.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why vite 8 produced zero suites instead of an error
&lt;/h2&gt;

&lt;p&gt;Vite 8 moved its transform pipeline to rolldown/oxc, and the module-runner contract changed with it.&lt;br&gt;
Vitest loaded all 71 files without throwing, evaluated them, and got no registered suites back.&lt;/p&gt;

&lt;p&gt;From the runner's point of view that is indistinguishable from a file with no &lt;code&gt;describe&lt;/code&gt;/&lt;code&gt;test&lt;/code&gt; in it —&lt;br&gt;
so it reported the honest thing it could see, once per file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No test suite found in file
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There was one fingerprint of the real cause in the noise, and I had been scrolling past it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The `esbuild` option is deprecated, please use `oxc` instead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That warning is vite 8 announcing itself. Nothing in the output ever named a version mismatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one character class
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# Capped below 8, and the missing cap is why the ENTIRE test suite was dead. This range had no upper&lt;/span&gt;
  &lt;span class="c1"&gt;# bound, so vite resolved to 8.1.4 while vitest 3.2.7 declares ^5||^6||^7.0.0-0. Vite 8 moved to&lt;/span&gt;
  &lt;span class="c1"&gt;# rolldown/oxc and changed the module-runner contract: vitest loaded all 71 test files, got no suites&lt;/span&gt;
  &lt;span class="c1"&gt;# back, and reported "No test suite found in file" for every one of them. `vitest run` then exits&lt;/span&gt;
  &lt;span class="c1"&gt;# non-zero with "Tests: no tests" — which reads like a config problem, not like 71 silently unrun&lt;/span&gt;
  &lt;span class="c1"&gt;# files, and it stayed unnoticed. Nothing else in either workspace depends on vite (`pnpm why vite`&lt;/span&gt;
  &lt;span class="c1"&gt;# lists only vitest, vite-node and @vitest/mocker), so the cap costs nothing. Raise it when vitest&lt;/span&gt;
  &lt;span class="c1"&gt;# supports vite 8.&lt;/span&gt;
  &lt;span class="na"&gt;vite&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;=6.4.3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;8"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before adding a cap, check who is actually asking for the package:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pnpm why vite
&lt;span class="go"&gt;  vitest
  vite-node
  @vitest/mocker
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the test runner. Nothing in either workspace consumes vite at build time or ships it to&lt;br&gt;
production, so constraining it has no blast radius. If the answer had included the framework or the&lt;br&gt;
bundler, the cap would have been a real decision instead of a free one.&lt;/p&gt;

&lt;p&gt;vite dropped to 7.3.6. All 71 files collected. &lt;strong&gt;845 tests, all passing.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Then one of them failed
&lt;/h2&gt;

&lt;p&gt;Not a regression from the dead window — the suite came back clean on everything that had been touched&lt;br&gt;
while it was down. The failure was older than that, and it was real.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;lifecycleColumns()&lt;/code&gt; reads the extracted facts for a deprecation event and turns them into database&lt;br&gt;
columns. It handled the shutdown date and the successor model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;facts&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;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/ shutdown$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;shutdownAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;shutdownAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;shutdownAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;model&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/ successor$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;successor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;successor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;after&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;There is no branch for the &lt;code&gt;deprecated&lt;/code&gt; date. So &lt;code&gt;events.announced_at&lt;/code&gt; was &lt;code&gt;NULL&lt;/code&gt; — not for some rows,&lt;br&gt;
for &lt;strong&gt;every row ever written&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That column is the start of the notice period. Without it you cannot compute how much warning a vendor&lt;br&gt;
actually gave before switching a model off, which for a site whose entire subject is model lifecycles is&lt;br&gt;
not a cosmetic gap.&lt;/p&gt;

&lt;p&gt;The test that catches it already existed, and had existed the whole time. Its name is&lt;br&gt;
&lt;code&gt;shutdown date, successor and announcement date&lt;/code&gt;, and thirty lines up it verifies the &lt;code&gt;occurredAt&lt;/code&gt; value&lt;br&gt;
&lt;code&gt;'2025-06-10T00:00:00.000Z'&lt;/code&gt; — the vendor's own "deprecated on" column. The extraction was there. The&lt;br&gt;
assertion was there. Only the mapping to a database column was missing, and the one process that would&lt;br&gt;
have said so was returning &lt;code&gt;no tests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is the missing branch:&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/ deprecated$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Earliest, for the same reason as shutdownAt: on a batch announcement the notice period a reader&lt;/span&gt;
  &lt;span class="c1"&gt;// is actually inside is the one that started first.&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;deprecatedAt&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;deprecatedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;deprecatedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;after&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The second guard was also down
&lt;/h2&gt;

&lt;p&gt;While fixing that I ran the type checker, which I had not done in a while either:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pnpm run typecheck
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="go"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rowMapper.ts&lt;/code&gt; line 98 was reading a &lt;code&gt;deprecatedAt&lt;/code&gt; field off the return value of a function whose&lt;br&gt;
declared return type did not have one. It could not compile. CI was in the same state.&lt;/p&gt;

&lt;p&gt;So both automated checks on this repository were failing, in two different ways, and neither of them was&lt;br&gt;
loud enough to interrupt anyone. One said &lt;code&gt;no tests&lt;/code&gt; and looked like configuration. The other exited 2 in&lt;br&gt;
a step nobody was reading.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A security override needs an upper bound too.&lt;/strong&gt; The reason you write one is that you know better than&lt;br&gt;
the resolver about a specific lower edge. That knowledge does not extend upward. &lt;code&gt;&amp;gt;=x&lt;/code&gt; published today&lt;br&gt;
is a bet on every major version that ships after you stop paying attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;no tests&lt;/code&gt; is a failure state, not a neutral one.&lt;/strong&gt; A run that collects zero suites should be treated&lt;br&gt;
the same as a run that fails, because the information content is identical: you do not know whether your&lt;br&gt;
code works. It exits non-zero already; the gap was in my reading, not in the tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check the peer range before overriding.&lt;/strong&gt; &lt;code&gt;vitest 3.2.7 → ^5 || ^6 || ^7.0.0-0&lt;/code&gt; was a two-second lookup&lt;br&gt;
that would have told me exactly where the ceiling belonged.&lt;/p&gt;

&lt;p&gt;The existing &lt;code&gt;announced_at&lt;/code&gt; values are still &lt;code&gt;NULL&lt;/code&gt;. Backfilling them needs a re-parse of the archived&lt;br&gt;
page snapshots, which is a separate job. The column is populated correctly going forward.&lt;/p&gt;




&lt;p&gt;I write these up while building &lt;a href="https://aichangewatch.com?src=devto" rel="noopener noreferrer"&gt;AI Change Watch&lt;/a&gt;, which crawls&lt;br&gt;
LLM vendor documentation and records every deprecation, shutdown date and price change with the date it&lt;br&gt;
happened.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
      <category>vite</category>
    </item>
    <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>
  </channel>
</rss>
