<?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>33 Things in Three Vendors' OpenAPI Specs Existed for Exactly One Day</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 06 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/33-things-in-three-vendors-openapi-specs-existed-for-exactly-one-day-do3</link>
      <guid>https://dev.to/ai_changewatch/33-things-in-three-vendors-openapi-specs-existed-for-exactly-one-day-do3</guid>
      <description>&lt;p&gt;An OpenAPI spec reads like a contract. It is versioned, it is machine-readable, it generates your&lt;br&gt;
client. It is also a file the vendor edits, and nothing in it records what it said last week.&lt;/p&gt;

&lt;p&gt;I have been diffing the published specs of OpenAI, Anthropic and Groq and keeping every key that&lt;br&gt;
appears: &lt;strong&gt;2,802 operations, parameters, enum variants and beta markers.&lt;/strong&gt; Of those, &lt;strong&gt;215 are no&lt;br&gt;
longer there.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;157   parameters
 29   enum variants
 27   whole operations
  2   beta markers
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The removals are not evenly aged. Sorted by how long each one was in the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; 76   over a year
 87   under a year
 17   under 30 days
 33   first seen and last seen on the SAME DAY
  2   excluded — a defect in my own collection, see the notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one I did not expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourteen of them landed on a single afternoon
&lt;/h2&gt;

&lt;p&gt;On 2025-03-22, OpenAI's spec gained four operations for fine-tuning checkpoint permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST   /fine_tuning/checkpoints/{permission_id}/permissions
GET    /fine_tuning/checkpoints/{permission_id}/permissions
DELETE /fine_tuning/checkpoints/{permission_id}/permissions
DELETE /fine_tuning/checkpoints/{fine_tuned_model_checkpoint}/permissions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the path parameters. Three of them key the collection on &lt;code&gt;{permission_id}&lt;/code&gt; — the id of a&lt;br&gt;
thing inside the collection you are listing. The fourth keys the same collection on the&lt;br&gt;
checkpoint. They cannot both be right, and a &lt;code&gt;GET .../{permission_id}/permissions&lt;/code&gt; that returns a&lt;br&gt;
list of permissions is a path that was never going to ship in that shape.&lt;/p&gt;

&lt;p&gt;The next capture did not have any of them. Fourteen keys — four operations and their ten&lt;br&gt;
parameters — in and out, same date.&lt;/p&gt;

&lt;h2&gt;
  
  
  And some land on endpoints you actually call
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /chat/completions&lt;/code&gt; carried a parameter called &lt;code&gt;instance_id&lt;/code&gt; on 2024-02-09 and on no other&lt;br&gt;
day I have.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;POST /responses&lt;/code&gt; listed &lt;code&gt;computer-preview&lt;/code&gt; as a &lt;code&gt;tools[]&lt;/code&gt; variant on 2025-03-11 and not&lt;br&gt;
afterwards.&lt;/p&gt;

&lt;p&gt;Anthropic's &lt;code&gt;POST /v1/messages&lt;/code&gt; carried &lt;code&gt;user_profile_id&lt;/code&gt; from 2026-04-16 to 2026-06-22 — 67&lt;br&gt;
days, long enough that a client generated in May has a field for it. Six of Anthropic's&lt;br&gt;
&lt;code&gt;/v1/environments&lt;/code&gt; operations carried a &lt;code&gt;sessionKey&lt;/code&gt; parameter on 2026-04-08 and lost it on&lt;br&gt;
2026-04-09. I do not know what any of these were for, and I am not going to guess; what I can say&lt;br&gt;
is that they were in the published document and then were not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does and does not mean
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;It does not mean the API changed.&lt;/strong&gt; This is the part I had to be careful about, and the clearest&lt;br&gt;
example is Anthropic's &lt;code&gt;tool_choice&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On 2024-10-03 three enum variants — &lt;code&gt;auto&lt;/code&gt;, &lt;code&gt;any&lt;/code&gt;, &lt;code&gt;tool&lt;/code&gt; — stop appearing. &lt;code&gt;tool_choice: auto&lt;/code&gt;&lt;br&gt;
obviously still works. What happened is that the schema moved those values behind a &lt;code&gt;$ref&lt;/code&gt;, and&lt;br&gt;
the shape my reader looks for stopped matching. The &lt;em&gt;parameter&lt;/em&gt; &lt;code&gt;tool_choice&lt;/code&gt; is still in the file&lt;br&gt;
and still marked present; only the three variant keys went to zero.&lt;/p&gt;

&lt;p&gt;So "removed from the spec" means &lt;strong&gt;the key is no longer where it was in the document&lt;/strong&gt;. Sometimes&lt;br&gt;
that is a deprecation. Sometimes it is a refactor of the YAML. The 27 removed operations are the&lt;br&gt;
subset I would trust furthest — a path disappearing is hard to do by accident — and even there,&lt;br&gt;
OpenAI's six Assistants file endpoints going on 2024-04-15 is the documented v2 migration, not a&lt;br&gt;
surprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does mean&lt;/strong&gt; is that the file is not a record of itself. If you want to know when&lt;br&gt;
&lt;code&gt;user_profile_id&lt;/code&gt; appeared, the current spec cannot tell you, and neither can the changelog —&lt;br&gt;
there was no entry for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Pin the spec file, not the URL.&lt;/strong&gt; Vendor it into your repo with a hash and diff it on a&lt;br&gt;
schedule. Every one of the findings above is a &lt;code&gt;git diff&lt;/code&gt; someone could have had for free; the&lt;br&gt;
only reason I have them is that nobody else kept the old copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat a generated client as a snapshot with a date on it.&lt;/strong&gt; A client generated between April and&lt;br&gt;
June of 2026 has an &lt;code&gt;user_profile_id&lt;/code&gt; field that no longer exists upstream. Nothing failed, and&lt;br&gt;
nothing told you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch for same-day keys before you build on one.&lt;/strong&gt; A capability that appears in the spec and&lt;br&gt;
nowhere in the changelog, docs or release notes is a capability that may not survive the week.&lt;br&gt;
Fourteen of OpenAI's did not survive the day.&lt;/p&gt;

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

&lt;p&gt;Three vendors only — OpenAI (1,605 keys), Anthropic (1,069) and Groq (128). They are the ones&lt;br&gt;
publishing a machine-readable spec at a stable URL; most of the vendors I track do not, which is&lt;br&gt;
why the comparison stops at three.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;first_seen&lt;/code&gt; is a &lt;strong&gt;floor&lt;/strong&gt;, not an introduction date, for 153 of the keys: 90 OpenAI, 38 Groq and&lt;br&gt;
25 Anthropic were already in the file on the first capture I have. Everything in this post keys&lt;br&gt;
off removals, where the date is observed rather than inferred — but a lifespan that starts at a&lt;br&gt;
floor is a lower bound.&lt;/p&gt;

&lt;p&gt;Two rows have a &lt;code&gt;last_seen&lt;/code&gt; earlier than their &lt;code&gt;first_seen&lt;/code&gt; (an OpenAI uploads parameter and a&lt;br&gt;
Groq &lt;code&gt;stream_options&lt;/code&gt;). That is a defect in my own collection, not a vendor doing something&lt;br&gt;
strange, and both are excluded from the 33.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The removed keys, per vendor, with the dates:&lt;br&gt;
&lt;a href="https://aichangewatch.com/api-features?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com/api-features&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>llm</category>
      <category>openapi</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Published a Median Model Lifespan. Seven Weeks Later the Same Query Said 219 Days More.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Fri, 02 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/i-published-a-median-model-lifespan-seven-weeks-later-the-same-query-said-219-days-more-1p65</link>
      <guid>https://dev.to/ai_changewatch/i-published-a-median-model-lifespan-seven-weeks-later-the-same-query-said-219-days-more-1p65</guid>
      <description>&lt;p&gt;On 2026-08-01 I computed how long an AI model lives, from the day it shows up to the day its&lt;br&gt;
vendor says it will be switched off. The answer was &lt;strong&gt;197 days&lt;/strong&gt;, over 57 models.&lt;/p&gt;

&lt;p&gt;I ran the same query on 2026-09-22. It now says &lt;strong&gt;416 days&lt;/strong&gt;, over 74 models.&lt;/p&gt;

&lt;p&gt;Nothing happened to the models. Seventeen models joined the sample, and the middle of it moved&lt;br&gt;
seven months. That is not a metric with noise in it; that is a metric whose value is mostly&lt;br&gt;
determined by who is in the denominator.&lt;/p&gt;

&lt;p&gt;Here is the denominator.&lt;/p&gt;

&lt;h2&gt;
  
  
  528, 290, 74
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;528   models where I have a recorded arrival date
290   models where a vendor has published a shutdown date
 74   models that have both        ← every lifespan number comes from here
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is 14% of the models I can name. The other 86% split into two piles, and both piles are&lt;br&gt;
excluded for reasons that are not random:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;454 models have an arrival and no announced end.&lt;/strong&gt; They are alive, or at least nobody has
said otherwise. A model that never gets a shutdown date never enters the average, no matter how
long it runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;216 models have a published shutdown date and no arrival I can point to.&lt;/strong&gt; Mostly vendors
whose deprecation table lists models their changelog never announced.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the measurable population is: models that were born after I started watching &lt;em&gt;and&lt;/em&gt; have since&lt;br&gt;
been sentenced. Long-lived models are systematically underrepresented, because a model that is&lt;br&gt;
still running contributes nothing until the day someone schedules its execution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The median is a fact about vendors, not about models
&lt;/h2&gt;

&lt;p&gt;Split the 74 by who made them:&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;models measurable&lt;/th&gt;
&lt;th&gt;median&lt;/th&gt;
&lt;th&gt;range&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;27&lt;/td&gt;
&lt;td&gt;211d&lt;/td&gt;
&lt;td&gt;7 – 499&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;488d&lt;/td&gt;
&lt;td&gt;212 – 1,017&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;537d&lt;/td&gt;
&lt;td&gt;272 – 1,240&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;535d&lt;/td&gt;
&lt;td&gt;483 – 920&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moonshot&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;183d&lt;/td&gt;
&lt;td&gt;183 – 188&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;608d&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Google is 36% of the sample and its median is less than half of everyone else's. Not because&lt;br&gt;
Google's models die young in some general sense, but because Google ships a lot of &lt;code&gt;-preview&lt;/code&gt;&lt;br&gt;
models and publishes end dates for them. The shortest span in the whole set is&lt;br&gt;
&lt;code&gt;gemini-3.1-pro-preview&lt;/code&gt;: &lt;strong&gt;7 days&lt;/strong&gt;, arriving 2026-03-01 and scheduled off 2026-03-08.&lt;/p&gt;

&lt;p&gt;Anthropic contributes exactly &lt;strong&gt;one&lt;/strong&gt; model to a statistic about model lifespans.&lt;/p&gt;

&lt;p&gt;Now re-read the headline number. "The median model lives 416 days" is, mechanically, "Google's&lt;br&gt;
preview cadence and Azure's back catalogue, mixed in whatever proportion happened to have both&lt;br&gt;
dates on record this week." Change which vendors publish end dates — not which models exist — and&lt;br&gt;
the number moves. Which is exactly what it did between August and September.&lt;/p&gt;

&lt;h2&gt;
  
  
  The longest life in the set is a join across two vendors
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;gpt-4&lt;/code&gt; measures 1,240 days: arrival 2023-06-01, shutdown 2026-10-23.&lt;/p&gt;

&lt;p&gt;The shutdown is OpenAI's, from OpenAI's deprecation page. The arrival is Azure's, from an Azure&lt;br&gt;
"What's New" entry — and Azure's What's New is monthly, so the day component is not a&lt;br&gt;
measurement, it is the first of the month. &lt;strong&gt;29 of the 74 arrival dates in this sample are month&lt;br&gt;
floors like that&lt;/strong&gt; — 39% of the population carries up to 30 days of error on one end.&lt;/p&gt;

&lt;p&gt;Is it wrong? It is the same model, and the metric is defined as &lt;em&gt;first record across the sources&lt;br&gt;
I read&lt;/em&gt;, which is what it says on the page. But "first record across sources" and "launch date"&lt;br&gt;
are different quantities, and the gap between them is entirely determined by which source I&lt;br&gt;
happened to add first.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually solid here
&lt;/h2&gt;

&lt;p&gt;Not the median. Three other things from the same rows are:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The spread.&lt;/strong&gt; Q1 is 216 days and Q3 is 570. The interquartile range is a year. Whatever the&lt;br&gt;
centre is, half the measurable models fall in a band that wide, which is enough on its own to&lt;br&gt;
say that planning against a "typical" model lifetime is not a thing you can do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;454 models have no announced end.&lt;/strong&gt; That is not a lifespan estimate, it is a disclosure fact,&lt;br&gt;
and it needs no denominator games: the vendor has published nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The vendors' own stated policies.&lt;/strong&gt; Anthropic publishes a 60-day minimum and AWS publishes six&lt;br&gt;
months. Those are commitments, not observations, and they do not move when my crawl coverage&lt;br&gt;
changes. If you need a number to plan against, use those — and note that only two vendors of the&lt;br&gt;
fifteen I read publish one at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Ask any "average model lifespan" figure for its n and its denominator.&lt;/strong&gt; Mine is 74 out of 528.&lt;br&gt;
A figure quoted without those two numbers is quoting the shape of somebody's crawl.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't derive a migration budget from a central tendency.&lt;/strong&gt; Derive it from the one date that is&lt;br&gt;
about your model: whether the vendor has published a shutdown date for the exact string you send&lt;br&gt;
in the API call, and what their stated notice period is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch the arrival side, not just the retirement side.&lt;/strong&gt; Every vendor here publishes retirements&lt;br&gt;
somewhere. Far fewer publish arrivals in a form you can date, which is precisely why 216 models&lt;br&gt;
have an end and no beginning.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this measures
&lt;/h2&gt;

&lt;p&gt;Published vendor documentation across 15 vendors, read on 2026-09-22, model names matched on&lt;br&gt;
normalised strings. A model is counted once, at its earliest published shutdown date. Successor&lt;br&gt;
models are excluded from the retirement side — counting them stamps the replacement with the&lt;br&gt;
retirement date of the model it replaces.&lt;/p&gt;

&lt;p&gt;By the time you read this the live figure will be different, and that is the point of the post&lt;br&gt;
rather than a disclaimer on it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The lifespan section, recomputed on every load, with the count it was drawn from:&lt;br&gt;
&lt;a href="https://aichangewatch.com/insights?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com/insights&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>api</category>
      <category>data</category>
    </item>
    <item>
      <title>8 of the 26 Codes Behind HTTP 429 Are Not Rate Limits. Backoff Will Never Clear Them.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Tue, 29 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/7-of-the-21-codes-behind-http-429-are-not-rate-limits-backoff-will-never-clear-them-33kc</link>
      <guid>https://dev.to/ai_changewatch/7-of-the-21-codes-behind-http-429-are-not-rate-limits-backoff-will-never-clear-them-33kc</guid>
      <description>&lt;p&gt;The retry-on-429 loop is the most copied piece of LLM plumbing there is. Catch the status, sleep,&lt;br&gt;
double the sleep, try again. Every SDK ships one and every tutorial recommends it.&lt;/p&gt;

&lt;p&gt;I read the published error documentation of 9 model vendors and pulled out every code they put&lt;br&gt;
behind HTTP 429. There are 26 of them. Here is what they actually mean — the right-hand column is&lt;br&gt;
the vendor's own stated cause, shortened to fit; nothing below is my paraphrase of their intent.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;anthropic  rate_limit_error                    A rate limit, OR the monthly spend cap. Same code.
aws        ThrottlingException                 Exceeding the account quotas for Amazon Bedrock.
aws        ModelNotReadyException              The model is not ready to serve inference requests.
cohere     Too Many Requests                   The rate limit has been exceeded.
deepseek   Rate Limit Reached                  You are sending requests too quickly.
google     rate_limit_exceeded                 Per-minute or per-second request or token limit.
google     too_many_requests                   Too many requests in a short period of time.
google     quota_exceeded                      You have exceeded your daily quota.
groq       Too Many Requests                   Too many requests in a given timeframe.
mistral    Too many requests                   Exceeded the rate limit for your subscription tier.
openai     Rate limit reached for requests     You are sending requests too quickly.
openai     Slow down                           Your request rate increased too quickly.
qwen       Throttling                          (no cause published)
qwen       Throttling.AllocationQuota          (no cause published)
qwen       Throttling.BurstRate                Call frequency spiked suddenly.
qwen       Throttling.RateQuota                Call frequency (RPS/RPM) triggered rate limiting.
qwen       Throttling.ServiceOverloaded        Network resources are in an overloaded state.
qwen       Throttling.ResourceExhausted        Network resources are in a saturated state.

openai     Credit balance exhausted            Your organization has no prepaid credits remaining.
openai     Organization spend limit reached    Your organization reached its enforced spend limit.
openai     Organization usage limit reached    Reached its OpenAI-assigned usage limit.
openai     Project spend limit reached         Your project reached its enforced spend limit.
qwen       CommodityNotPurchased               Workspace subscription not purchased.
qwen       PrepaidBillOverdue                  Workspace prepaid bill expired.
qwen       PostpaidBillOverdue                 Model inference service expired.
qwen       BudgetLimitExceeded                 The configured budget has been exhausted.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The blank line is mine — the vendors publish one list. Everything under it is documented as a&lt;br&gt;
billing or account state, not a rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vendors say so themselves
&lt;/h2&gt;

&lt;p&gt;I did not classify these. The vendors did, in the remedy they print next to the code:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;openai / Credit balance exhausted&lt;/strong&gt; — "Add credits to continue using the API."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;openai / Project spend limit reached&lt;/strong&gt; — "Increase or remove the spend limit in your project&lt;br&gt;
settings."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;qwen / CommodityNotPurchased&lt;/strong&gt; — "Purchase workspace service first."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;qwen / BudgetLimitExceeded&lt;/strong&gt; — "You will be unable to make further API calls until the budget&lt;br&gt;
limit is increased or reset."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;All four of the OpenAI ones and all four Qwen ones need a human to spend money or raise a&lt;br&gt;
limit. No amount of sleeping changes them. A loop that retries a 429 six times with exponential&lt;br&gt;
backoff spends about a minute confirming that your credit card is still not on file, and then&lt;br&gt;
raises the same error it would have raised immediately — except now it looks like a transient&lt;br&gt;
capacity problem in your logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same condition is a 402 at four other vendors
&lt;/h2&gt;

&lt;p&gt;This is the part that makes the status code useless as a signal.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;condition&lt;/th&gt;
&lt;th&gt;vendor&lt;/th&gt;
&lt;th&gt;status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;out of money&lt;/td&gt;
&lt;td&gt;DeepSeek&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;402&lt;/strong&gt; Insufficient Balance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;out of money&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;402&lt;/strong&gt; billing_error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;out of money&lt;/td&gt;
&lt;td&gt;Cohere&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;402&lt;/strong&gt; Payment Required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;out of money&lt;/td&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;402&lt;/strong&gt; payment_required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;out of money&lt;/td&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;429&lt;/strong&gt; Credit balance exhausted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;out of money&lt;/td&gt;
&lt;td&gt;Qwen&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;429&lt;/strong&gt; PrepaidBillOverdue&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"You have run out of balance" is DeepSeek's 402. "Your organization has no prepaid credits&lt;br&gt;
remaining" is OpenAI's 429. Same sentence, different half of the 4xx range.&lt;/p&gt;

&lt;p&gt;If you run more than one vendor behind one client — which is the entire point of most gateway&lt;br&gt;
code — a handler that branches on the status code will fail-fast on DeepSeek and retry-loop on&lt;br&gt;
OpenAI, for the identical business condition.&lt;/p&gt;

&lt;p&gt;Google, which serves both, spells out the consequence in the remedy on its own 402:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Add credits to your billing account, or turn on auto-reload. &lt;strong&gt;Don't retry: the request won't&lt;br&gt;
succeed until credits are added.&lt;/strong&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That instruction is correct, and it is printed on the status code that nobody retries anyway. The&lt;br&gt;
codes that actually end up inside retry loops are the 429s, and that is where it is missing.&lt;/p&gt;

&lt;p&gt;Qwen does it inside a single vendor. "Workspace subscription not purchased" is&lt;br&gt;
&lt;code&gt;CommodityNotPurchased&lt;/code&gt;, a &lt;strong&gt;429&lt;/strong&gt;. "Model Studio service is not activated" is&lt;br&gt;
&lt;code&gt;AccessDenied.Unpurchased&lt;/code&gt;, a &lt;strong&gt;403&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anthropic puts both meanings on one code
&lt;/h2&gt;

&lt;p&gt;Branching on the vendor's code instead of the status is the obvious fix, and for one vendor it is&lt;br&gt;
not enough. &lt;code&gt;rate_limit_error&lt;/code&gt; is Anthropic's only 429, and its documented cause now reads:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Your organization has hit a rate limit, reached its usage tier's monthly spend cap, or reached&lt;br&gt;
a spend limit on the Claude Code workspace. &lt;strong&gt;A tier spend-cap 429 has no retry-after header and&lt;br&gt;
keeps failing until access resumes.&lt;/strong&gt;"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One code, one status, and the two cases need opposite handling. Anthropic tells you how to tell&lt;br&gt;
them apart — the absence of &lt;code&gt;Retry-After&lt;/code&gt; — which means the discriminator is a missing header.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more that aren't what they look like
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;aws / ModelNotReadyException&lt;/code&gt;&lt;/strong&gt; is a 429 and has nothing to do with your request rate. The&lt;br&gt;
model is warming. AWS documents that its own SDK "will automatically retry the operation up to 5&lt;br&gt;
times" — so if you wrapped the SDK in your own retry loop, you have 5×N attempts and a timeout&lt;br&gt;
budget you did not plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;google / quota_exceeded&lt;/code&gt;&lt;/strong&gt; is a 429 that says "You have exceeded your &lt;strong&gt;daily&lt;/strong&gt; quota." Google's&lt;br&gt;
own remedy is "Wait until the quota resets or request a quota increase." An exponential backoff&lt;br&gt;
capped at a few minutes will exhaust itself against a counter that resets tomorrow. Google&lt;br&gt;
publishes a separate code — &lt;code&gt;rate_limit_exceeded&lt;/code&gt; — for the per-minute case, and that one's remedy&lt;br&gt;
does say "Wait and retry with exponential backoff." One vendor, one status, two opposite&lt;br&gt;
instructions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 26 actually split into
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;15   clear on their own if you back off
 1   a rate limit OR a spend cap, on one code          anthropic / rate_limit_error
 1   clears at the daily reset, not on your timer      google / quota_exceeded
 1   clears when the model finishes warming            aws / ModelNotReadyException
 8   do not clear until a person spends money
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only 3 of the 9 vendors mention the &lt;code&gt;Retry-After&lt;/code&gt; header anywhere in their error documentation —&lt;br&gt;
OpenAI, Mistral, and Anthropic, whose mention is that the spend-cap case does not send one. The&lt;br&gt;
header may well be on the wire elsewhere; it is not in the docs you would read to write the&lt;br&gt;
handler.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Branch on the vendor's code, not the status.&lt;/strong&gt; Every vendor here returns a machine-readable code&lt;br&gt;
in the body. The status tells you which half of the 4xx range someone chose; the code usually&lt;br&gt;
tells you what happened — with Anthropic's as the exception you have to special-case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give the money errors their own path.&lt;/strong&gt; They are not retryable and they are not transient —&lt;br&gt;
they are an outage with a billing cause, and they should page someone rather than dissolve into a&lt;br&gt;
retry counter. Alerting on "429 rate" makes exactly this class invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cap unknown 429s, don't trust them.&lt;/strong&gt; Treating an unrecognized 429 as retryable is fine.&lt;br&gt;
Retrying it 6 times with a 60-second ceiling because "429 means slow down" is what turns a&lt;br&gt;
five-second failure into a minute of held connections.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this does and does not measure
&lt;/h2&gt;

&lt;p&gt;This is the vendors' &lt;strong&gt;published documentation&lt;/strong&gt;, read on 2026-09-28 — not traffic. If a vendor&lt;br&gt;
returns a code it never documented, I do not have it, and several of these docs are visibly&lt;br&gt;
incomplete: Qwen publishes no cause text at all for two of its four throttling codes.&lt;/p&gt;

&lt;p&gt;It moves, too. A week before publishing this I counted 21 codes, not 26: Google has since&lt;br&gt;
documented &lt;code&gt;too_many_requests&lt;/code&gt; and a 402, OpenAI a second rate code, Qwen a budget one and two&lt;br&gt;
capacity ones, and Anthropic rewrote its single cause to name the spend cap. A post about error&lt;br&gt;
semantics going stale in under a week is not an aside — it is the same reason the handler you&lt;br&gt;
wrote last year is describing a set of conditions that no longer matches.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Corrected 2026-10-03.&lt;/strong&gt; This went out saying 24 codes. It was 26: my own extractor was reading&lt;br&gt;
Qwen's page with a selector the vendor had changed, and it dropped two of their throttling codes&lt;br&gt;
along with the cause and remedy text on 36 rows. The vendor's page was right the whole time; the&lt;br&gt;
count I published was mine being wrong about it. Fixed, and the piece now matches the page.&lt;/p&gt;

&lt;p&gt;That cuts one way only. Every code above is one the vendor chose to write down, and the eight that&lt;br&gt;
need a credit card are documented as clearly as the ones that need patience. They are behind the&lt;br&gt;
same three digits anyway.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I track these pages and keep what they overwrite. The 182 documented error codes across 9&lt;br&gt;
vendors, one page each, with the vendor's own cause and remedy:&lt;br&gt;
&lt;a href="https://aichangewatch.com/errors?src=devto" rel="noopener noreferrer"&gt;aichangewatch.com/errors&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>A Vendor Deleted a Shutdown Date and Nothing Told Anyone. So I Counted the Next 90 Days.</title>
      <dc:creator>ushiro</dc:creator>
      <pubDate>Thu, 24 Sep 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ai_changewatch/a-vendor-deleted-a-shutdown-date-and-nothing-told-anyone-so-i-counted-the-next-90-days-17n4</link>
      <guid>https://dev.to/ai_changewatch/a-vendor-deleted-a-shutdown-date-and-nothing-told-anyone-so-i-counted-the-next-90-days-17n4</guid>
      <description>&lt;p&gt;On 2026-07-28, Google's deprecation page listed October 16, 2026 as the shutdown date for&lt;br&gt;
&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;.&lt;/p&gt;

&lt;p&gt;On 2026-08-03, the date was gone.&lt;/p&gt;

&lt;p&gt;As I write this the same page reads "No shutdown date announced" for all three, and the string&lt;br&gt;
&lt;code&gt;2026-10-16&lt;/code&gt; appears on it zero times. No changelog entry, no banner, no email. The page simply&lt;br&gt;
says something different than it did a week earlier.&lt;/p&gt;

&lt;p&gt;Nothing was wrong on either day. A team that read it in late July scheduled a migration against&lt;br&gt;
an October deadline. A team that read it a week later saw no deadline at all. Both read the&lt;br&gt;
vendor's own documentation. Neither had any way to know the other reading existed.&lt;/p&gt;

&lt;p&gt;That is not carelessness. It is what a page does when it stores &lt;em&gt;state&lt;/em&gt; instead of &lt;em&gt;history&lt;/em&gt;: it&lt;br&gt;
can be correct every single day and still never tell you it changed. I wrote that one case up&lt;br&gt;
with the full before-and-after&lt;br&gt;
&lt;a href="https://dev.to/ai_changewatch/the-same-claude-model-gives-you-60-days-notice-on-one-platform-and-184-on-another-347p"&gt;here&lt;/a&gt;;&lt;br&gt;
this post is what happened when I stopped looking at one case and counted all of them.&lt;/p&gt;

&lt;p&gt;I run a crawler over the deprecation pages of 15 model vendors for exactly this reason — it reads&lt;br&gt;
them daily and keeps what they overwrite. Which means I can ask a question the pages themselves&lt;br&gt;
can't answer: what does the next quarter actually look like, across all of them at once?&lt;/p&gt;
&lt;h2&gt;
  
  
  The next 90 days
&lt;/h2&gt;

&lt;p&gt;62 models are scheduled for removal between 2026-09-24 and 2026-12-22.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sep (24-30)   12
Oct           28
Nov           12
Dec (1-22)    10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By vendor. The two right-hand columns turned out to matter more than the count:&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;shutdowns&lt;/th&gt;
&lt;th&gt;names a successor&lt;/th&gt;
&lt;th&gt;states when it was announced&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;36&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;xAI&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;62&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;48&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Read those two columns as a pair
&lt;/h2&gt;

&lt;p&gt;Migrating off a model is two questions. &lt;em&gt;What do I move to&lt;/em&gt;, and &lt;em&gt;how much time do I have.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;OpenAI, Azure and Google answer the first one well — 47 of their 55 shutdowns name a replacement.&lt;br&gt;
None of them tell you when the clock started. AWS is the exact mirror: all six of its entries&lt;br&gt;
carry an announcement date, and not one names a successor.&lt;/p&gt;

&lt;p&gt;Across all 62, exactly one record carries both facts.&lt;/p&gt;

&lt;p&gt;It is &lt;code&gt;gpt-5.4-cyber&lt;/code&gt;. Announced 2026-09-11, removed 2026-10-01, with &lt;code&gt;gpt-5.6-cyber&lt;/code&gt; named as&lt;br&gt;
the target. &lt;strong&gt;Twenty days.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For scale, Anthropic publishes a 60-day minimum and AWS publishes six months, both as stated&lt;br&gt;
policy you can go and read. Those are commitments you could hold someone to, and the gap between&lt;br&gt;
them is real: the same Claude model gets 60 days of notice on one platform and 184 on the other,&lt;br&gt;
which I measured &lt;a href="https://dev.to/ai_changewatch/the-same-claude-model-gives-you-60-days-notice-on-one-platform-and-184-on-another-347p"&gt;in the same write-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;There is no published policy behind the twenty. It is just what the page happened to say that&lt;br&gt;
week.&lt;/p&gt;

&lt;p&gt;So the one entry this quarter that answers both halves is also the shortest runway in it. That&lt;br&gt;
reads less like coincidence than like a shape: a vendor that publishes an announcement date has&lt;br&gt;
made notice into something measurable, and a vendor that doesn't has nothing to be measured&lt;br&gt;
against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The column I refuse to fill in
&lt;/h2&gt;

&lt;p&gt;"States when it was announced" means the vendor printed a date on its own page. When they don't,&lt;br&gt;
I leave the field null.&lt;/p&gt;

&lt;p&gt;I do &lt;strong&gt;not&lt;/strong&gt; substitute the timestamp of my own crawler, even though I have one for every record&lt;br&gt;
and it would fill that column nicely. My crawler runs hourly, so its timestamp measures my&lt;br&gt;
polling interval plus whenever the vendor got around to editing. Writing that into a field&lt;br&gt;
labelled &lt;em&gt;announced&lt;/em&gt; would turn my latency into a vendor commitment.&lt;/p&gt;

&lt;p&gt;Seven out of 62 is an unsatisfying number to publish. It is also the only honest one.&lt;/p&gt;

&lt;p&gt;The successor column follows the same rule: it holds the model the vendor names, not the model I&lt;br&gt;
think you should use.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ones that can't be counted at all
&lt;/h2&gt;

&lt;p&gt;34 deprecations in the archive were announced with &lt;strong&gt;no shutdown date&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;They aren't in the 62 and they never will be, because a calendar view has nowhere to put them.&lt;br&gt;
If you are running one of those, your model is formally deprecated, has no announced end, and&lt;br&gt;
will not appear in any quarterly list until somebody assigns it a date — possibly with twenty&lt;br&gt;
days' notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would actually do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Four of these dates land within a week of this post.&lt;/strong&gt; &lt;code&gt;sora-2&lt;/code&gt; and &lt;code&gt;sora-2-pro&lt;/code&gt; plus three dated&lt;br&gt;
variants go on&lt;br&gt;
2026-09-24, no successor named. &lt;code&gt;babbage-002&lt;/code&gt;, &lt;code&gt;davinci-002&lt;/code&gt;, &lt;code&gt;gpt-3.5-turbo-1106&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;gpt-3.5-turbo-instruct&lt;/code&gt; go on 2026-09-28. AWS retires Nova Canvas and Nova Reel on 2026-09-30.&lt;br&gt;
Azure retires &lt;code&gt;gpt-4o&lt;/code&gt; and &lt;code&gt;o3-mini&lt;/code&gt; on 2026-10-01.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin the dated model string, not the alias.&lt;/strong&gt; A lot of the entries above are dated variants —&lt;br&gt;
&lt;code&gt;sora-2-2025-10-06&lt;/code&gt; and its siblings. Point your code at the undated name and you are on whatever&lt;br&gt;
the vendor last mapped it to, and the variants do not all retire on the same day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snapshot the page you are planning against.&lt;/strong&gt; This is the one that would have caught the Google&lt;br&gt;
case, and it does not require what I built. A scheduled job that &lt;code&gt;curl&lt;/code&gt;s the deprecation page and&lt;br&gt;
commits the HTML to a repo gives you a diff and a date. The entire problem is that you cannot see&lt;br&gt;
July's version from inside August — a cron job and 20 lines of YAML fixes that for one vendor.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The full 62 with dates and successors, plus a page per model:&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>devops</category>
      <category>api</category>
    </item>
    <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>
  </channel>
</rss>
