<?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: Eugen Taranowski</title>
    <description>The latest articles on DEV Community by Eugen Taranowski (@eugen_taranowski).</description>
    <link>https://dev.to/eugen_taranowski</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%2F1933715%2F6441288c-769e-4712-8056-3dbfbe462a46.jpeg</url>
      <title>DEV Community: Eugen Taranowski</title>
      <link>https://dev.to/eugen_taranowski</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/eugen_taranowski"/>
    <language>en</language>
    <item>
      <title>Almost every crawl error on our domain came from a site that didn't exist yet</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Mon, 14 Sep 2026 06:55:07 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/almost-every-crawl-error-on-our-domain-came-from-a-site-that-didnt-exist-yet-2m62</link>
      <guid>https://dev.to/eugen_taranowski/almost-every-crawl-error-on-our-domain-came-from-a-site-that-didnt-exist-yet-2m62</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://watchnext.leyu.studio/blog/crawl-errors-from-a-site-that-didnt-exist-yet" rel="noopener noreferrer"&gt;the WatchNext blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Google had crawled WatchNext and indexed none of it. The coverage report listed 11 pages as &lt;em&gt;crawled, currently not indexed&lt;/em&gt; and 200 as &lt;em&gt;discovered, currently not indexed&lt;/em&gt; — found, but never fetched.&lt;/p&gt;

&lt;p&gt;The crawl stats report had what looked like the reason. Of 453 crawl requests over the month, 17% had ended in a DNS error. If Google couldn't reliably resolve the site, it would hardly be surprising that it wasn't indexing it.&lt;/p&gt;

&lt;p&gt;None of those errors were WatchNext's.&lt;/p&gt;

&lt;h2&gt;
  
  
  The report was about four sites
&lt;/h2&gt;

&lt;p&gt;WatchNext lives at &lt;code&gt;watchnext.leyu.studio&lt;/code&gt;. Search Console had been set up as a &lt;em&gt;domain property&lt;/em&gt; for &lt;code&gt;leyu.studio&lt;/code&gt;, which is the convenient choice: one verification covers every subdomain. It also means every number in the report is the sum of every subdomain, with nothing on the overview telling you so.&lt;/p&gt;

&lt;p&gt;The hosts table, further down the same report, splits it out:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Host&lt;/th&gt;
&lt;th&gt;Crawl requests&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;shop.leyu.studio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;234&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;watchnext.leyu.studio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;134&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;leyu.studio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;checkout.leyu.studio&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An online shop, its checkout, this app, and the bare domain. The "WatchNext crawl report" was a report on four sites, of which WatchNext was a little under a third.&lt;/p&gt;

&lt;h2&gt;
  
  
  The site that didn't exist
&lt;/h2&gt;

&lt;p&gt;Look at the third row. &lt;code&gt;leyu.studio&lt;/code&gt; itself had nothing on it — no page, no server, and critically no DNS address record. The subdomains all pointed somewhere; the bare domain pointed nowhere, and its &lt;code&gt;www&lt;/code&gt; variant didn't exist at all.&lt;/p&gt;

&lt;p&gt;Google had still made 77 requests to it. And the DNS error rate, 17.00% of 453, is 77 requests. Almost every failure in the report — 77 of the 81 failed requests, with the other four being ordinary 404s — was Google repeatedly trying to reach a site that had never been built.&lt;/p&gt;

&lt;p&gt;That match is worth trusting, and the reason is not that the numbers are equal. It's that there is a mechanism which forces them to be. A hostname with no address record cannot get past DNS, so every one of its requests &lt;em&gt;has&lt;/em&gt; to fail at exactly that stage and no other. The count is the consequence of the cause.&lt;/p&gt;

&lt;p&gt;The daily chart is consistent with it, too. On each of the last five days of the export, every request recorded no bytes downloaded and a response time of zero — which is what a request looks like when there was never a server to talk to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One thing we can't explain: the same hosts table lists all four hosts with a status of "No problems", including the one that couldn't resolve. Whatever that column checks, it evidently isn't the fate of individual requests, so we didn't lean on it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The match that meant nothing
&lt;/h2&gt;

&lt;p&gt;Here is the part worth writing down, because it very nearly went into the diagnosis as a finding.&lt;/p&gt;

&lt;p&gt;The crawl report also breaks requests down by file type, and 8.17% of them were HTML. WatchNext received 134 requests. Multiply one by the other and you get almost exactly 11 page fetches — and the coverage report said exactly 11 pages had been crawled and not indexed.&lt;/p&gt;

&lt;p&gt;That is a beautiful result. Google had fetched precisely the 11 pages it then declined to index, and never got round to the other 200. It explains the coverage report completely.&lt;/p&gt;

&lt;p&gt;It is also not a result at all. The 8.17% is the HTML share across &lt;em&gt;all four hosts&lt;/em&gt; combined. Applying it to WatchNext alone assumes a Next.js app and a Shopify storefront request the same mix of JavaScript, images, stylesheets and pages — and there is no reason on earth they would. The multiplication borrows a ratio from a population it doesn't describe, and the tidy answer it produces is a coincidence.&lt;/p&gt;

&lt;p&gt;So two numbers matched perfectly in the same investigation, one after the other, and only one of the matches meant anything. The difference between them is the difference between a count that a cause forces and a count that arithmetic happens to land on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually going on with WatchNext
&lt;/h2&gt;

&lt;p&gt;With the apex's errors set aside, the rest of the report is quieter and more useful. 99.56% of crawl requests were refreshes of URLs Google already knew about; 0.44% were discovery. Google was barely looking for anything new.&lt;/p&gt;

&lt;p&gt;It is tempting to add that the busiest day of crawling — 113 requests on 14 August — came three days before we committed the &lt;a href="https://watchnext.leyu.studio/blog/one-line-that-disabled-server-rendering" rel="noopener noreferrer"&gt;fix that gave these pages server-rendered content&lt;/a&gt;, so Google's opinion of the site would have been formed on the broken version. But that chart is for the whole property too, and it doesn't say which host those 113 requests went to. It's the same trap as the HTML share, one section later.&lt;/p&gt;

&lt;p&gt;The evidence that does hold up is narrower. Inspecting a single post showed Google had found it through a link from another site, crawled it on 20 August — after the fix — fetched it successfully, and read the canonical URL we had declared. And it still hadn't indexed it. Nothing in that is a technical fault. It points at a young domain that Google has no particular reason to trust yet, which no DNS record will change.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we did about it
&lt;/h2&gt;

&lt;p&gt;We built something at &lt;code&gt;leyu.studio&lt;/code&gt;, so the bare domain now resolves to a real page instead of nothing, and &lt;code&gt;www&lt;/code&gt; redirects to it. We also added a separate URL-prefix property for each site, so WatchNext's report is only about WatchNext.&lt;/p&gt;

&lt;p&gt;It is worth being plain about what that fixes. It removes the noise: the property should stop reporting a failure rate that was never about the app. It does not, by itself, get a single WatchNext page indexed. Those were never the same problem — they only looked like one because they were summed into the same number.&lt;/p&gt;

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

&lt;p&gt;When two numbers from different places match exactly, it feels like confirmation, and it is very hard to argue with. Two questions separate the matches that mean something from the ones that don't.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Is there a mechanism that forces them to be equal?&lt;/em&gt; A hostname with no address can only fail at DNS, so its request count and its DNS failure count must agree. Nothing forces an 8% share to produce exactly the right number of page fetches; it just did.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Does the ratio describe the thing you applied it to?&lt;/em&gt; A percentage is a statement about a population. Apply it to a subset with a different make-up and it will still return a number — precise, plausible and wrong.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The new site went up on 12 September. Search Console's reports take weeks to reflect a change like this, so we don't yet know what the numbers look like afterwards — and given the rest of this post, we're not going to guess. We'll add them here when they exist.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://watchnext.leyu.studio/" rel="noopener noreferrer"&gt;WatchNext&lt;/a&gt; is a deliberately simple TV tracker: it tells you when the next episode of your favourite shows airs, for any show, in any country — and nothing else.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>debugging</category>
      <category>google</category>
    </item>
    <item>
      <title>Three things change when you put Cloudflare in front of your host</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:39:40 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/three-things-change-when-you-put-cloudflare-in-front-of-your-host-32c4</link>
      <guid>https://dev.to/eugen_taranowski/three-things-change-when-you-put-cloudflare-in-front-of-your-host-32c4</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://watchnext.leyu.studio/blog/cloudflare-in-front-of-your-host" rel="noopener noreferrer"&gt;the WatchNext blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;WatchNext's DNS lives on Cloudflare with the proxy enabled, in front of Netlify. That arrangement is extremely common and almost entirely uneventful: requests get faster, the origin gets shielded, and nothing appears to change.&lt;/p&gt;

&lt;p&gt;Nothing &lt;em&gt;appears&lt;/em&gt; to. Three things did, and none of them failed loudly enough to notice on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your host's country header starts describing your datacenter
&lt;/h2&gt;

&lt;p&gt;WatchNext uses a visitor's country for a small set of defaults: which streaming services to show beside a title, which age rating to display, and — if you ask for it — which shows were made where you live. None of that touches air dates, which always follow the show's own country. But get the visitor's country wrong and all three quietly point somewhere else.&lt;/p&gt;

&lt;p&gt;That used to be a browser-side call to a third-party IP geolocation service. Which meant every visitor's IP address was handed to an undisclosed company on their first page load, before any consent, and the app inherited that service's rate limits and downtime. The request already arrives at our own infrastructure, and hosts attach the country they resolved at the edge, so the answer was available for free with no extra party involved.&lt;/p&gt;

&lt;p&gt;Every platform spells it differently — Netlify sends &lt;code&gt;x-nf-geo&lt;/code&gt; as base64-encoded JSON, Vercel sends &lt;code&gt;x-vercel-ip-country&lt;/code&gt;, Cloudflare sends &lt;code&gt;cf-ipcountry&lt;/code&gt; — so the sensible thing is to read whichever is present.&lt;/p&gt;

&lt;p&gt;Here is the part that matters once a proxy is in front. Your origin no longer receives connections from visitors. It receives them from Cloudflare. So the header &lt;em&gt;your host&lt;/em&gt; generates describes the machine that relayed the request — a datacenter — rather than the person who made it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cf-ipcountry&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;                 &lt;span class="c1"&gt;// Cloudflare (proxied)&lt;/span&gt;
  &lt;span class="nf"&gt;readNetlifyGeo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-nf-geo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;     &lt;span class="c1"&gt;// Netlify&lt;/span&gt;
  &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-vercel-ip-country&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;          &lt;span class="c1"&gt;// Vercel&lt;/span&gt;
  &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-country&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt;                    &lt;span class="c1"&gt;// other proxies / self-hosted&lt;/span&gt;
  &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudflare first, deliberately. &lt;code&gt;cf-ipcountry&lt;/code&gt; only exists when Cloudflare is actually proxying, and when it exists it is the only header in that list still describing the visitor. When Cloudflare isn't in front, it is simply absent and the host's own value is correct again.&lt;/p&gt;

&lt;p&gt;The reason this is worth a section rather than a footnote is the failure mode. A geolocation lookup reading the wrong header does not throw, log anything, or return an obviously bogus value. It returns a real, well-formed country code — just the datacenter's. Our requests have come through Amsterdam and Munich on different days. For a largely European audience, "Netherlands" and "Germany" are answers plausible enough to survive a casual look at the page.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Two more Cloudflare values worth handling: &lt;code&gt;XX&lt;/code&gt; when it can't determine a country, and &lt;code&gt;T1&lt;/code&gt; for traffic arriving over Tor. Both are two ASCII letters, so both sail through a naive &lt;code&gt;/^[A-Za-z]{2}$/&lt;/code&gt; check and become a "country" your lookups will never match.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  2. Responses start coming from a layer you didn't configure
&lt;/h2&gt;

&lt;p&gt;After the proxy goes on, every response says this:&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;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cloudflare&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which tells you almost nothing. It identifies who handed the response over, not who produced it. The useful signal is whether your &lt;em&gt;host's&lt;/em&gt; fingerprint is still on it — on Netlify that is &lt;code&gt;x-nf-request-id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We found this while looking at something trivial: a &lt;code&gt;www&lt;/code&gt;-to-apex redirect on the studio site next door. It reported &lt;code&gt;server: cloudflare&lt;/code&gt;, and also carried an &lt;code&gt;x-nf-request-id&lt;/code&gt;. Both at once, which is the tell — Netlify generated that redirect and Cloudflare merely relayed it. The request had travelled all the way to the origin to be told to go somewhere else.&lt;/p&gt;

&lt;p&gt;Arriving over plain HTTP cost two of those round trips: one to upgrade to HTTPS, another to drop the &lt;code&gt;www&lt;/code&gt;. Moving the rule into Cloudflare's own redirect rules, so it is answered at the edge, collapsed it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;http://www&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 hops, both from the origin&lt;/td&gt;
&lt;td&gt;1 hop, from the edge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;https://www&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 hop, from the origin&lt;/td&gt;
&lt;td&gt;1 hop, from the edge&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Afterwards the 301 still says &lt;code&gt;server: cloudflare&lt;/code&gt; — but no longer carries &lt;code&gt;x-nf-request-id&lt;/code&gt;, which is how you know the rule is really being served at the edge rather than quietly passing through.&lt;/p&gt;

&lt;p&gt;The same question is worth asking about headers. WatchNext's security headers are declared by the application and emitted by its runtime, and you can see that from the outside: they arrive on responses that carry the host's request id. When a header you've configured somewhere isn't appearing, "which layer is supposed to be adding this, and did the response even come from there?" is usually a faster question than re-reading the config.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Your privacy policy has a processor it doesn't name
&lt;/h2&gt;

&lt;p&gt;This is the one we got wrong for a while, and it is the reason this post exists.&lt;/p&gt;

&lt;p&gt;Our privacy policy named our host. It did not name Cloudflare. But once the proxy is on, &lt;em&gt;every single request&lt;/em&gt; reaches Cloudflare before it reaches the host — so Cloudflare processes every visitor's IP address, user agent and requested URL. And, per the section above, we actively depend on it doing so: the country behind those streaming services, ratings and local shows is a value Cloudflare computed.&lt;/p&gt;

&lt;p&gt;That is not a technicality about a CDN. In the arrangement we had, the party doing the most visible processing of visitor data was the one the policy didn't mention. So Cloudflare went into all four places that enumerate who touches the data: the usage-data description, the list of service providers, the international-transfers section, and the third-party policy links.&lt;/p&gt;

&lt;p&gt;One addition mattered more than the rest. Cloudflare may set a strictly necessary security cookie when it sees suspicious traffic. Our cookie section claims to enumerate everything that gets stored on your device — so leaving that out didn't merely omit something, it made a statement we were publishing inaccurate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Nothing here is legal advice, and the specific obligations differ by jurisdiction — check yours. The engineering point is the transferable one: adding a proxy adds an organisation that processes your visitors' personal data. That makes it a documentation change as much as an infrastructure change, and the documentation is the half that has no deploy step to remind you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The pattern underneath all three
&lt;/h2&gt;

&lt;p&gt;A proxy is not a transparent pipe. It terminates TLS, it can answer requests without consulting your origin at all, it changes what your origin is able to see about the client, and it handles personal data on the way through.&lt;/p&gt;

&lt;p&gt;Every one of those is genuinely useful — that is what you turned it on for. But each has a consequence you have to go looking for, because the symptom of getting it wrong is never an error. It is a page that loads faster while quietly telling someone in Vietnam what airs tonight in Amsterdam.&lt;/p&gt;

&lt;p&gt;Two questions catch most of it. &lt;em&gt;Who actually answered this response?&lt;/em&gt; — check for your host's fingerprint rather than trusting the &lt;code&gt;server&lt;/code&gt; header. And &lt;em&gt;what did each layer see?&lt;/em&gt; — because anything that sees a visitor's IP address is a party you are relying on, whether or not you have written it down.&lt;/p&gt;

&lt;p&gt;We had both answers available the whole time, in response headers anyone can read with &lt;code&gt;curl -I&lt;/code&gt;. We just hadn't thought to ask until a redirect looked one hop too long.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 14 September 2026:&lt;/strong&gt; an earlier version of this post said WatchNext uses the visitor's country to localise air dates. It doesn't. Dates always follow the show's own country; the visitor's country only sets default streaming services, age ratings and the local-shows filter. A reader's question about VPNs prompted the recheck.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://watchnext.leyu.studio/" rel="noopener noreferrer"&gt;WatchNext&lt;/a&gt; is a deliberately simple TV tracker: it tells you when the next episode of your favourite shows airs, for any show, in any country — and nothing else.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>webdev</category>
      <category>privacy</category>
      <category>devops</category>
    </item>
    <item>
      <title>The test that failed every morning and passed every afternoon</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:13:57 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/the-test-that-failed-every-morning-and-passed-every-afternoon-35pa</link>
      <guid>https://dev.to/eugen_taranowski/the-test-that-failed-every-morning-and-passed-every-afternoon-35pa</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://watchnext.leyu.studio/blog/test-that-failed-every-morning" rel="noopener noreferrer"&gt;the WatchNext blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While making an unrelated change — adding a processor to a privacy page and a link to a footer — the test suite came back with eleven passes and one failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- 1
&lt;/span&gt;&lt;span class="gi"&gt;+ 2
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt; ❯ tests/airDate.test.ts:125:47
    125|     expect(getAirDateDaysDiff(plus(1), "US")).toBe(1);
       |                                               ^
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An episode airing tomorrow was being counted as two days away. That is about as load-bearing as a bug gets in an app whose entire purpose is telling you when the next episode airs.&lt;/p&gt;

&lt;p&gt;It turned out the countdown was fine. The test was wrong, in a way that made it fail for roughly a third of every day and pass for the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: prove it isn't your change
&lt;/h2&gt;

&lt;p&gt;The edited files were a privacy page, a footer and a markdown document. The failing test covers air-date arithmetic. Those are obviously unrelated — but "obviously unrelated" is a hypothesis, and the cost of checking it is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git stash &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npx vitest run   &lt;span class="c"&gt;# same failure&lt;/span&gt;
git stash pop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical failure with the changes removed. That single result reframes everything that follows: this is not a regression being debugged, it is an existing defect being discovered. Without it, the natural next move is to start reading your own diff for a cause that was never in it — and the worst outcome of that search is "fixing" code of your own that was correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then: rule out the machine
&lt;/h2&gt;

&lt;p&gt;A test about day counting failing intermittently points at timezones, so the first suspect is the machine's own zone. That is easy to test by simply telling the process it lives somewhere else:&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;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Europe/Dublin     npx vitest run   &lt;span class="c"&gt;# 1 failed&lt;/span&gt;
&lt;span class="nv"&gt;TZ&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;America/New_York  npx vitest run   &lt;span class="c"&gt;# 1 failed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both fail, identically. That is a useful negative: the host timezone changes what the process calls local time, but it does not change the instant at which the test runs. So the hidden dependency is not &lt;em&gt;where&lt;/em&gt; the test runs. It is &lt;em&gt;when&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two calendars, one comparison
&lt;/h2&gt;

&lt;p&gt;Here is the test as written. It builds "tomorrow" and "yesterday" by adding and subtracting a day in milliseconds, then trimming the result to a date:&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;today&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;iso&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;d&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="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;plus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;iso&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&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;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getAirDateDaysDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;plus&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getAirDateDaysDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks unimpeachable, and it contains the whole bug. &lt;code&gt;toISOString()&lt;/code&gt; always formats in UTC. So the date handed to the function is "tomorrow according to UTC".&lt;/p&gt;

&lt;p&gt;The function, though, deliberately does not count days in UTC. It counts them in the show's own country's timezone, and for a US show that means &lt;code&gt;America/Los_Angeles&lt;/code&gt;. That choice is not an accident or an oversight — it is the fix for &lt;a href="https://watchnext.leyu.studio/blog/apple-tv-air-dates-off-by-one" rel="noopener noreferrer"&gt;an earlier bug where a show's card and its notification disagreed&lt;/a&gt; about whether the same episode aired today or tomorrow, because one of them mixed the viewer's timezone into the comparison.&lt;/p&gt;

&lt;p&gt;So the test measures from one calendar and the function measures from another. For most of the day they agree. For the hours when UTC has already rolled over to a new date and Los Angeles has not, they do not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Moment&lt;/th&gt;
&lt;th&gt;Test asks about&lt;/th&gt;
&lt;th&gt;Function's today&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;15:00 UTC, 11 Sept&lt;/td&gt;
&lt;td&gt;12 Sept&lt;/td&gt;
&lt;td&gt;11 Sept (LA)&lt;/td&gt;
&lt;td&gt;1 ✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;06:39 UTC, 12 Sept&lt;/td&gt;
&lt;td&gt;13 Sept&lt;/td&gt;
&lt;td&gt;11 Sept (LA)&lt;/td&gt;
&lt;td&gt;2 ✗&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At 06:39 UTC on 12 September it is still 23:39 on 11 September in Los Angeles. UTC's "tomorrow" is 13 September. Los Angeles's "today" is the 11th. Two days apart, and the assertion wanted one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which means it fails on a timetable
&lt;/h2&gt;

&lt;p&gt;The failure window is exactly the offset between the two zones: seven hours while Los Angeles is on daylight time, eight hours when it isn't. Midnight UTC to around 07:00 UTC, every single day. After that it goes green again on its own.&lt;/p&gt;

&lt;p&gt;Two things about that are worse than an ordinary broken test. The first is that it presents as a feature bug — the assertion says an episode airing tomorrow is being counted as two days out, which is precisely what a real off-by-one in the countdown would look like. The second is that re-running it later makes it pass, which quietly teaches everyone that it is "flaky" and can be re-run rather than read.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and what it deliberately doesn't cover
&lt;/h2&gt;

&lt;p&gt;The repair is to build the fixture in the same frame the function measures in, rather than in UTC:&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;zone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;timeZoneData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iso_3166_1&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;ianaTimeZone&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;plus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;DateTime&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="nf"&gt;setZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zone&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;plus&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;toISODate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The zone is looked up from the same table the application uses, so the test does not hardcode a mapping that could later drift out of step with the code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That has a real cost worth naming: because the fixture now derives its zone from the same table as the code under test, this test can no longer catch a wrong entry in that table. It is not trying to. Its job is the day arithmetic, and covering the mapping is a different test with fixed, hand-written dates. A test that quietly tests two things is how you end up with one that fails for reasons its name doesn't explain.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Checking the fix didn't just silence it
&lt;/h2&gt;

&lt;p&gt;The easiest way to make a failing test pass is to adjust it until it agrees with whatever the code currently does. That is not a fix; it is a deletion with extra steps. So the fixed test was checked the same way the original air-date work was — by breaking the code on purpose and confirming the test notices.&lt;/p&gt;

&lt;p&gt;The mutation was to reintroduce exactly the bug this block of tests is named after, replacing the zone-aware "now" with a plain local one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- const now = zone ? DateTime.now().setZone(zone) : DateTime.now();
&lt;/span&gt;&lt;span class="gi"&gt;+ const now = DateTime.now();
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Red again, as it should be. The test still guards the thing it was written to guard; it simply no longer reports a failure that depends on what time you asked.&lt;/p&gt;

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

&lt;p&gt;Any test that calls &lt;code&gt;new Date()&lt;/code&gt; has an input that isn't written down anywhere in it. Most of the time that input is harmless. It stops being harmless the moment the code under test cares about which calendar it is using — and if you are dealing with schedules, billing periods, delivery estimates or anything else with a day boundary in it, your code cares.&lt;/p&gt;

&lt;p&gt;There are two honest ways out. Freeze the clock, so "now" is a fixed value you chose. Or, as here, build your fixtures in the same timezone the code measures in, so there is only one calendar in the comparison.&lt;/p&gt;

&lt;p&gt;What does not work is the thing that feels most neutral: reaching for UTC in a test because UTC feels like the absence of a timezone. It isn't. In a codebase that reasons in a show's local calendar, UTC is simply a third timezone that nobody asked for — and it will agree with the right answer often enough, and for long enough, to get committed.&lt;/p&gt;

&lt;h2&gt;
  
  
  After this went up: a reader pushed on the caveat
&lt;/h2&gt;

&lt;p&gt;The note above admits that the repaired test can no longer catch a wrong entry in the timezone table, because it now reads that table itself. A reader asked the obvious follow-up: is anything pinned outside that frame, so the two calendars can still disagree on purpose?&lt;/p&gt;

&lt;p&gt;They also supplied a better description of the problem than the one in this post's title — &lt;em&gt;a test with a schedule is not flaky, it is deterministic and on a timer&lt;/em&gt; — along with the observation that a retry policy hides exactly that, forever.&lt;/p&gt;

&lt;p&gt;The honest answer was "partly, and by accident", which is worth showing as a measurement rather than an opinion. Pointing &lt;code&gt;US&lt;/code&gt; at &lt;code&gt;Europe/Berlin&lt;/code&gt; fails three other tests, because the day-shift logic compares the origin's offset against Dublin and Berlin is not behind it. But pointing it at &lt;code&gt;America/New_York&lt;/code&gt; — still behind Dublin, still entirely plausible — failed &lt;strong&gt;nothing at all&lt;/strong&gt;. The mapping was pinned only insofar as a zone sits east or west of Ireland.&lt;/p&gt;

&lt;p&gt;Three things are pinned now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A frozen instant&lt;/strong&gt;, which was the reader's suggestion, with one correction to it. The obvious choice of a small-hours UTC time does not discriminate: at 02:00 or 03:00 UTC, Los Angeles and New York are still on the same calendar day and agree on every answer here. 06:00 UTC is where they split — 23:00 on the 11th in Los Angeles, 02:00 on the 12th in New York.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;vi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setSystemTime&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-09-12T06:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="c1"&gt;// In Los Angeles it is still the 11th, so the 12th is tomorrow.&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getAirDateDaysDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-09-12&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="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getAirDateDaysDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-09-13&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="s2"&gt;US&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both halves hardcoded, nothing read from the table. The &lt;code&gt;America/New_York&lt;/code&gt; drift now fails this test, and only this test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The daylight-saving boundary&lt;/strong&gt; — where checking the claim turned up that we had been wrong about our own code. The assumption was that &lt;code&gt;Math.round&lt;/code&gt; was what kept day counting correct across a 23-hour day. It isn't: Luxon's difference between two &lt;code&gt;startOf("day")&lt;/code&gt; values is calendar arithmetic, so the transition was already handled and the rounding was doing nothing. Worth pinning anyway, because the obvious simplification does break — a plain millisecond difference across Los Angeles's spring-forward is 0.9583, which floors to zero. An episode airing tomorrow, reported as airing today, one day a year, in one timezone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A boundary case better than a frozen instant.&lt;/strong&gt; Côte d'Ivoire sits at UTC+0 all year and observes no daylight saving. Ireland does. So the same origin country is behind the release zone during Irish summer time and exactly level with it in winter — and the shift correctly applies in one case and not the other. That pair cannot be satisfied by a hardcoded list of "western" origin countries, whichever way the list is written, and it puts the Dublin transition itself under test, since that is the thing that moves the answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The last part of the question was whether CI runs the suite inside the failure window. It wasn't running the suite at all — there was no test job, only a nightly data-refresh workflow, which is a large part of how a test that failed for seven hours a day survived as long as it did. There is one now, and it deliberately does not pin a timezone: these tests are supposed to hold wherever and whenever they run, and forcing a zone would hide the exact class of bug they exist to catch.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;&lt;em&gt;&lt;a href="https://watchnext.leyu.studio/" rel="noopener noreferrer"&gt;WatchNext&lt;/a&gt; is a deliberately simple TV tracker: it tells you when the next episode of your favourite shows airs, for any show, in any country — and nothing else.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>javascript</category>
      <category>timezones</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I gave it four facts and it invented a fifth</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:59:12 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/i-gave-it-four-facts-and-it-invented-a-fifth-5a91</link>
      <guid>https://dev.to/eugen_taranowski/i-gave-it-four-facts-and-it-invented-a-fifth-5a91</guid>
      <description>&lt;p&gt;Show data on my TV tracker comes from &lt;a href="https://www.themoviedb.org" rel="noopener noreferrer"&gt;TMDB&lt;/a&gt;, like it does for a great many TV apps. That includes the synopsis — which means the paragraph on my page for a given show is the same paragraph on TMDB itself, on JustWatch, on Trakt, and on every other app built from the same API.&lt;/p&gt;

&lt;p&gt;Duplicate text isn't a penalty. It just can't win anything. Those are the pages meant to answer "when is the next episode of X", and the only original thing on them was my own countdown.&lt;/p&gt;

&lt;p&gt;So: generate something. I have a machine on the LAN running a 35B model, which is more than enough to write a paragraph. The interesting part turned out to be everything I had to forbid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The obvious thing to generate is the wrong thing
&lt;/h2&gt;

&lt;p&gt;The instinct is to rewrite the synopsis. Same information, different words, no longer duplicate. I didn't, for two reasons.&lt;/p&gt;

&lt;p&gt;The first is that it lands squarely in what Google calls &lt;strong&gt;scaled content abuse&lt;/strong&gt; — generating many pages without adding value. A reworded plot summary is a different arrangement of the same information: high volume, nothing new. Whether or not it trips anything, it's hard to argue you've added something the reader didn't have.&lt;/p&gt;

&lt;p&gt;The second is simpler. &lt;strong&gt;Nobody searches for a synopsis.&lt;/strong&gt; People type "is Silo weekly or all at once", "what day does Silo come out", "how many episodes in season 3". A rewritten plot summary matches none of that.&lt;/p&gt;

&lt;p&gt;What does match it is release cadence — and cadence isn't a field. Nobody has it, because it has to be derived:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Modal gap between consecutive episode air dates, not the mean:&lt;/span&gt;
&lt;span class="c1"&gt;// one abnormal break (a strike, a pandemic) drags an average enough&lt;/span&gt;
&lt;span class="c1"&gt;// to describe an annual show as arriving every three years.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gaps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;dates&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;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;gaps&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dates&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="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;days&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;days&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// 7 -&amp;gt; weekly, 1 -&amp;gt; daily, 0 -&amp;gt; all at once&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same trick over season premieres gives "new seasons have arrived roughly every two years", which is genuinely useful and which no other TV site states.&lt;/p&gt;

&lt;h2&gt;
  
  
  The division of labour
&lt;/h2&gt;

&lt;p&gt;This is the part worth copying, if anything here is: &lt;strong&gt;my code derives the facts, and the model is only ever asked to turn them into sentences.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It receives a small JSON object and a rule that everything in the paragraph must come from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"House of the Dragon"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"networks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"HBO"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Returning Series"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cadence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weekly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"releaseWeekday"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sunday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"numberOfSeasons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"firstAirYear"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2022&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;If the model supplied the facts too, the failure mode would be confident invention across several hundred pages, on a site whose entire premise is telling people a date accurately. Not a risk worth taking to save writing a function.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then it invented things anyway
&lt;/h2&gt;

&lt;p&gt;Four failures, in the order I found them. None threw an error. Each would have been published.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It explained an internal flag, backwards.&lt;/strong&gt; My facts included a boolean recording that TMDB stores this show's dates a day before the network advertises them — &lt;a href="https://watchnext.leyu.studio/blog/apple-tv-air-dates-off-by-one" rel="noopener noreferrer"&gt;a real convention I correct for&lt;/a&gt;. Handed that flag, the model wrote:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New episodes are released weekly, typically arriving one day before the scheduled Thursday air date.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Which isn't what the flag means, isn't true, and is meaningless to a reader. I stopped giving it that field. &lt;strong&gt;Facts a model can't phrase safely don't belong in its input.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It wrote a date into text meant to last months.&lt;/strong&gt; One note ended "…with the next installment airing tomorrow." The entire design keeps dates out of the stored text and computes them live on every render, precisely so nothing goes stale — and the model reached for "tomorrow" anyway. My validation rejected months and years. It did not reject relative time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It described a running show as finished.&lt;/strong&gt; Given &lt;code&gt;numberOfSeasons: 4&lt;/code&gt; it wrote "has completed four seasons" about a series airing its fourth. That field is how many seasons &lt;em&gt;exist&lt;/em&gt;, not how many have ended. An easy thing for a person to misread too — but a person misreads it once, not two hundred times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And then it made something up.&lt;/strong&gt; At temperature 0.5:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The third season follows three years after the second.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence appears nowhere in its input. It came from the model's own knowledge of the show, in direct violation of an instruction telling it not to, and it reads exactly like the sentences around it that were true.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;Prompt rules for what a rule can fix, and a validator for what it can't:&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;RELATIVE_TIME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(&lt;/span&gt;&lt;span class="sr"&gt;today|tomorrow|tonight|yesterday|this week|next week|right now&lt;/span&gt;&lt;span class="se"&gt;)\b&lt;/span&gt;&lt;span class="sr"&gt;/i&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;rejectReason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ShowFacts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Check everything EXCEPT the show's own name — "The Tonight Show&lt;/span&gt;
  &lt;span class="c1"&gt;// Starring Jimmy Fallon" was rejected for containing "Tonight".&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;withoutTitle&lt;/span&gt; &lt;span class="o"&gt;=&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;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;titlePattern&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;facts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MONTHS&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;withoutTitle&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;names a month&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;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;withoutTitle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RELATIVE_TIME&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;rel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`relative time ("&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;")`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...stray years, spelled-out large numbers&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rejected output is regenerated, twice, then skipped.&lt;/p&gt;

&lt;p&gt;That last check is worth a note on &lt;strong&gt;where to stop tuning a prompt&lt;/strong&gt;. A prompt rule took spelled-out numbers from three notes in twenty down to one, and no further. Past that point another sentence of instruction was worth less than four lines of regex the retry loop enforces. A model can be asked; a check can insist.&lt;/p&gt;

&lt;p&gt;Temperature went 0.5 → 0.2 → 0.35. At 0.2 the invention stopped and every note became structurally identical — the same sentence with the values swapped. Once the fact rules were strong enough to carry the discipline themselves, 0.35 bought back sentence variety without the invention returning. I checked that by running the offending show five times, rather than assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The validator had its own bugs, of course
&lt;/h2&gt;

&lt;p&gt;It rejected &lt;em&gt;The Tonight Show Starring Jimmy Fallon&lt;/em&gt; for containing "tonight", and &lt;em&gt;Reply 1988&lt;/em&gt; for naming a year. Both were in the show's own title. The fix — strip the title before checking — was already in place for two of the four checks and had simply never been applied to the others.&lt;/p&gt;

&lt;p&gt;It also rejected two shows for naming the year they &lt;em&gt;ended&lt;/em&gt;, which can never go stale and should always have been allowed. "Aired from 2011 to 2020" is strictly better than trailing off.&lt;/p&gt;

&lt;p&gt;Final run: &lt;strong&gt;251 notes, one failure.&lt;/strong&gt; That one still has no note, because it was rejected twice for a badly formatted number. No note is better than a bad one, and a pipeline that can decline to publish is worth more than one that always produces something.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Give a model facts to phrase, not questions to answer.&lt;/strong&gt; Everything that went wrong was the model reaching past its input — for a fact it knew, for a word that felt natural, for an explanation of something it had been handed but didn't understand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whatever you forbid in the prompt, check for in code.&lt;/strong&gt; Every one of these failures was already forbidden in writing. The rules weren't ignored so much as outweighed by whatever made the sentence read well.&lt;/p&gt;

&lt;p&gt;And the thing that made the output useful wasn't the model at all. It was spending an afternoon working out which facts were worth having — cadence, release weekday, the gap between seasons — none of which existed as fields, all of which had to be computed first.&lt;/p&gt;

&lt;p&gt;The model wrote the sentences. The value was in what it was given to say.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>One line of JavaScript disabled server rendering on 190 pages</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Wed, 19 Aug 2026 18:58:05 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/one-line-of-javascript-disabled-server-rendering-on-190-pages-5akb</link>
      <guid>https://dev.to/eugen_taranowski/one-line-of-javascript-disabled-server-rendering-on-190-pages-5akb</guid>
      <description>&lt;p&gt;A while ago I did the SEO work properly. Every show page on my TV tracker got a real title and description instead of the site-wide default, an Open Graph image, a sitemap pulling in the shows people actually search for. It took a day. It worked — I checked the pages, the tags were there, the previews rendered.&lt;/p&gt;

&lt;p&gt;Months later Google Search Console reported that roughly 190 of those pages were &lt;strong&gt;soft 404s&lt;/strong&gt;: URLs that return a success status but look, to a crawler, like an error page.&lt;/p&gt;

&lt;p&gt;The obvious readings were all wrong. The pages returned 200. They weren't empty, duplicated, or thin. Open any of them and you get a show, a poster, a trailer, a cast list, an episode list. By any measure available in a browser, they were fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Looking at what was actually sent
&lt;/h2&gt;

&lt;p&gt;The thing I had never done was look at the HTML the &lt;em&gt;server&lt;/em&gt; sent, as opposed to the page the browser ended up showing. Those are different documents, and only the first is what a crawler sees first.&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://watchnext.leyu.studio/info/125988 | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;[^&amp;gt;]*&amp;gt;//g'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That left &lt;strong&gt;one character&lt;/strong&gt; of body text.&lt;/p&gt;

&lt;p&gt;Not a truncated page. Not a slow page. The server was sending an empty shell and the entire visible site was being assembled in the browser afterwards. Everything I had verified existed only after JavaScript ran. To a crawler taking a first look there was nothing on the page at all — which is exactly what a soft 404 means.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;

&lt;p&gt;Somewhere in a context provider wrapping the whole app, one line read the window width during render, to pick a short label over a long one:&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;isMobile&lt;/span&gt; &lt;span class="o"&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;innerWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server there is no &lt;code&gt;window&lt;/code&gt;. That line throws &lt;code&gt;ReferenceError: window is not defined&lt;/code&gt; every single time the page renders on the server.&lt;/p&gt;

&lt;p&gt;Here is the part that turns a small mistake into a large one. &lt;strong&gt;Next.js does not fail the build, and it does not show an error.&lt;/strong&gt; It catches the exception, gives up on server rendering that page, and falls back to rendering in the browser. Which works. The user gets the page, slightly later, and nothing anywhere says anything went wrong.&lt;/p&gt;

&lt;p&gt;That's a reasonable thing for a framework to do — degrading to a working page beats showing a visitor a stack trace. The cost is that the signal disappears along with the failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why every kind of testing I did missed it
&lt;/h2&gt;

&lt;p&gt;Look at what doesn't catch this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The build passes&lt;/strong&gt; — nothing is statically wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript passes&lt;/strong&gt; — &lt;code&gt;window.innerWidth&lt;/code&gt; is a perfectly well-typed expression.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The dev server is quiet.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clicking around works&lt;/strong&gt; — by then you're in the browser, where &lt;code&gt;window&lt;/code&gt; exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lighthouse scores fine&lt;/strong&gt; — it runs JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharing a link gives a correct preview&lt;/strong&gt; — metadata comes from a separate server function that never touched the broken code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every tool I had was either running JavaScript or checking something orthogonal. The one observer that behaves differently — a crawler forming a first impression from raw HTML — was the one I had no feedback loop from, until Search Console told me months after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, in two parts
&lt;/h2&gt;

&lt;p&gt;The line itself is easy. Keep the value in state, start at something true on the server, set the real value after mount:&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="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsMobile&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsMobile&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;innerWidth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;640&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;sync&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="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server renders the desktop label, the browser corrects it immediately if needed, and the first render matches on both sides so there's no hydration mismatch either.&lt;/p&gt;

&lt;p&gt;The second part was less obvious. With server rendering restored, the show pages served their layout — but the show data was still fetched in the browser, so the server was sending a page reading "Loading show details…". Technically server-rendered. Still nothing to read.&lt;/p&gt;

&lt;p&gt;So the page component became a server component that fetches the show and hands it to the client component as an initial value:&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;// page.tsx — server component&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&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;show&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getShow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ShowDetailsClient&lt;/span&gt; &lt;span class="na"&gt;initialShow&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="si"&gt;}&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ShowDetailsClient.tsx — "use client"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setShow&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialShow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;initialShow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initialShow&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="c1"&gt;// already have it, don't refetch&lt;/span&gt;
  &lt;span class="c1"&gt;// ...client fetch for the navigation case&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;initialShow&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;getShow()&lt;/code&gt; is used by &lt;code&gt;generateMetadata&lt;/code&gt; and by the page body. Next deduplicates the call, so this costs one request, not two.&lt;/p&gt;

&lt;p&gt;After both changes, the measurement that returned one character returned about &lt;strong&gt;1,750&lt;/strong&gt;, with a real &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, the overview, and the genre and description sections — all present before any JavaScript runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to take from it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Touching a browser API during render doesn't fail loudly. It fails by turning off the thing you can't see from a browser.&lt;/strong&gt; That's the whole lesson, and it generalises past this one property: &lt;code&gt;document&lt;/code&gt;, &lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;navigator&lt;/code&gt;, and anything reaching them indirectly through a library.&lt;/p&gt;

&lt;p&gt;The practical check takes ten seconds, and I now do it after any change to a page that matters for search:&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;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/&amp;lt;[^&amp;gt;]*&amp;gt;//g'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the result is a spinner, a loading message, or nothing at all, your page isn't server-rendered regardless of what it looks like in a tab. View-source works just as well. The point is only that you have to look at the document the &lt;em&gt;server&lt;/em&gt; sent, because that's the document the crawler judged.&lt;/p&gt;

&lt;p&gt;The wider version is worth saying plainly. I wrote the metadata, verified it, and moved on — and the verification happened entirely inside the environment where the bug couldn't appear. The work was real and the checking was real, and neither was worth much, because both happened on the wrong side of the line.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the second bug on this site whose root cause was the gap between what the server produces and what the browser ends up with. &lt;a href="https://watchnext.leyu.studio/blog/hydration-race-deleted-favorites" rel="noopener noreferrer"&gt;The other one deleted people's saved shows&lt;/a&gt;, and also only showed up on a path developers rarely take.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>seo</category>
    </item>
    <item>
      <title>The bug that only happened if you bookmarked the page</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Mon, 17 Aug 2026 10:31:47 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/the-bug-that-only-happened-if-you-bookmarked-the-page-1d02</link>
      <guid>https://dev.to/eugen_taranowski/the-bug-that-only-happened-if-you-bookmarked-the-page-1d02</guid>
      <description>&lt;p&gt;I found this by accident, while chasing something else entirely. Testing an unrelated date bug, I loaded the Favorites page directly by URL — and the test data I'd just saved was gone. Not "failed to display". Gone from storage.&lt;/p&gt;

&lt;p&gt;The strange part: clicking through to the same page from inside the app worked perfectly, every time. Same page, same code, same data. The only difference was how you arrived.&lt;/p&gt;

&lt;p&gt;That asymmetry turned out to be the entire explanation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two reasonable decisions
&lt;/h2&gt;

&lt;p&gt;The first piece is a hydration fix. The app stores your saved shows in &lt;code&gt;localStorage&lt;/code&gt;. The obvious way to load them is to read storage when the state is first created:&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="nx"&gt;favorites&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFavorites&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;favorites&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;That works, until you server-render. The server has no &lt;code&gt;localStorage&lt;/code&gt;, so it renders an empty list. A returning visitor's browser &lt;em&gt;does&lt;/em&gt; have the data, so it renders a full one. React compares the two, finds they disagree, and complains about a hydration mismatch.&lt;/p&gt;

&lt;p&gt;The standard fix — and the one I'd applied earlier — is to stop reading storage during render. Start empty on both server and client so the first render matches, then load the real data in an effect afterwards:&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="nx"&gt;favorites&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFavorites&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;favorites&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setFavorites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second piece is ordinary product behaviour: when you open Favorites, if the show data hasn't been refreshed in twelve hours, fetch fresh details for each saved show and store the result.&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="nf"&gt;useEffect&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="k"&gt;if &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="nx"&gt;lastRefresh&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TWELVE_HOURS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;refreshFavorites&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// maps over `favorites`, writes result to localStorage&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;Both correct. Both, in isolation, uncontroversial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they collide
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;React runs effects from the bottom of the tree upward.&lt;/strong&gt; Children first, then their parents.&lt;/p&gt;

&lt;p&gt;When you land directly on the Favorites page, the app's data provider and the page itself mount together, in the same commit. So:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The page's effect runs first. It checks the timestamp, decides a refresh is due, and calls the refresh function.&lt;/li&gt;
&lt;li&gt;That function reads the current list of favorites — which, at this exact moment, is still the empty array everything started as, because the provider's effect (a &lt;em&gt;parent&lt;/em&gt; effect) hasn't run yet.&lt;/li&gt;
&lt;li&gt;So it refreshes a list of zero shows. It receives zero shows back. It writes that result to &lt;code&gt;localStorage&lt;/code&gt;, overwriting whatever was there.&lt;/li&gt;
&lt;li&gt;A moment later the provider's effect runs, reads storage to restore your favorites, and finds an empty array. Because it just was one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hydration fix wasn't the mistake — it was the right call, and reverting it would just bring back the bug it solved. The mistake was not noticing that deferring the load created a window where the data legitimately isn't there yet, and that something else was already running inside that window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why normal use never showed it
&lt;/h2&gt;

&lt;p&gt;Navigate to Favorites by clicking a link and the provider is already mounted from whatever page you were on. Its effect ran long ago. The favorites are loaded. The page mounts alone, the refresh reads a populated list, and everything works.&lt;/p&gt;

&lt;p&gt;The bug needs the provider and the page to mount in the same commit, which only happens on a fresh load of that specific URL: a bookmark, a refresh while sitting on the page, a link shared from outside, or reopening a tab.&lt;/p&gt;

&lt;p&gt;Which is a genuinely unpleasant profile for a data-loss bug. It skips the path developers use constantly while building — clicking around a running app — and hits the path a returning user is most likely to take. Someone who bookmarks the page they care about is exactly the person with the most to lose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix, and a better one from the comments
&lt;/h2&gt;

&lt;p&gt;My first fix was a flag distinguishing "empty" from "not loaded yet":&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="nx"&gt;hydrated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setHydrated&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&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="k"&gt;try&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;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;favorites&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setFavorites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stored&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setHydrated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// runs even if the parse throws&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;with the page waiting on it before refreshing.&lt;/p&gt;

&lt;p&gt;That works. But when I posted this, a commenter pointed out it's weaker than it looks: it's a guard every future caller has to remember to check — structurally the same shape as the original bug, &lt;em&gt;correct only as long as nobody forgets&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The stronger version: &lt;strong&gt;make the refresh read storage directly rather than trusting React state.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refreshFavorites&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;favorites&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;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// ...refresh `saved`, write the result back&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt; &lt;span class="c1"&gt;// no dependency on component state at all&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now there is no ordering in which the refresh can observe fewer items than are actually saved. The overwrite isn't guarded against — it's impossible. I removed the flag from the write path entirely and re-tested the original failure scenario &lt;em&gt;with the guard gone&lt;/em&gt;, to confirm the safety was structural rather than conditional.&lt;/p&gt;

&lt;p&gt;The same commenter made a second point I'd missed: the empty state was rendering "No favorites added yet" during that same pre-hydration window — telling a returning user their list was gone, a moment before it appeared. "Empty" and "not loaded yet" have to be distinguishable when you &lt;em&gt;render&lt;/em&gt;, too, not just when you write.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;p&gt;Deferring work to fix one problem creates a window where your state is temporarily untrue. That's fine, as long as nothing else acts during it. Worth asking, whenever you move initialisation into an effect: what else runs before this, and what will it think the state means?&lt;/p&gt;

&lt;p&gt;More generally: "empty" and "not loaded yet" looking identical is a recurring source of this kind of damage. If code can act on the difference, it needs to be able to see the difference.&lt;/p&gt;

&lt;p&gt;And the reason I found it at all is that I loaded a page the way a user would, rather than the way I always did.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Why TMDB says your Apple TV+ show airs a day early</title>
      <dc:creator>Eugen Taranowski</dc:creator>
      <pubDate>Mon, 17 Aug 2026 10:22:43 +0000</pubDate>
      <link>https://dev.to/eugen_taranowski/why-tmdb-says-your-apple-tv-show-airs-a-day-early-3862</link>
      <guid>https://dev.to/eugen_taranowski/why-tmdb-says-your-apple-tv-show-airs-a-day-early-3862</guid>
      <description>&lt;p&gt;A user reported that my TV tracker was showing the wrong air date for &lt;em&gt;Silo&lt;/em&gt;. Their favourites card said the next episode aired today. Google said tomorrow. One of us was wrong, and the obvious assumption was that it was me.&lt;/p&gt;

&lt;p&gt;It was — but not for the reason I first thought, and the underlying cause is worth writing down, because it affects any app built on the same data.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, a genuine bug of my own
&lt;/h2&gt;

&lt;p&gt;The initial report was that the same episode showed different countdowns in different places in the app: a show card said "airs tomorrow" while the notification for the same episode said "airs today".&lt;/p&gt;

&lt;p&gt;That one was mine. Two different pieces of code were deciding what "today" meant in two different ways. One compared calendar days entirely within the show's own country's timezone. The other took the show's end-of-day cutoff, converted that instant into the &lt;em&gt;viewer's&lt;/em&gt; local timezone, and compared calendar days there.&lt;/p&gt;

&lt;p&gt;Those two approaches agree only when the show's timezone and the viewer's are close together. For a US show viewed from Germany — a nine-hour gap — they routinely land on different calendar days, shifting the whole today/tomorrow boundary depending on what time of day you happened to look.&lt;/p&gt;

&lt;p&gt;I fixed it by making both read from one shared function that only ever counts days in a single timezone. The two displays then agreed with each other perfectly.&lt;/p&gt;

&lt;p&gt;They were also both still wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that wasn't my code
&lt;/h2&gt;

&lt;p&gt;With the app now internally consistent, &lt;em&gt;Silo&lt;/em&gt; still said "airs today" for an episode that Apple, and everyone reporting on it, placed the following day. So I checked the source data against published release dates.&lt;/p&gt;

&lt;p&gt;Like a great many TV apps, this one gets show data from &lt;a href="https://www.themoviedb.org/" rel="noopener noreferrer"&gt;TMDB&lt;/a&gt;. Here's what its &lt;code&gt;air_date&lt;/code&gt; field said for two consecutive &lt;em&gt;Silo&lt;/em&gt; episodes, next to the dates in press coverage:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Episode&lt;/th&gt;
&lt;th&gt;TMDB&lt;/th&gt;
&lt;th&gt;Actually released&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3E6 "The Drive"&lt;/td&gt;
&lt;td&gt;Thu 6 Aug&lt;/td&gt;
&lt;td&gt;Fri 7 Aug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3E7 "Radio"&lt;/td&gt;
&lt;td&gt;Thu 13 Aug&lt;/td&gt;
&lt;td&gt;Fri 14 Aug&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both exactly one day early. Both a Thursday where the real release was a Friday — matching &lt;em&gt;Silo&lt;/em&gt;'s actual weekly Friday schedule. Two consecutive episodes ruled out a one-off typo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the date is "wrong" on purpose
&lt;/h2&gt;

&lt;p&gt;It isn't a typo. TMDB's convention is that &lt;code&gt;air_date&lt;/code&gt; records the &lt;strong&gt;earliest calendar day an episode becomes available in its country of origin&lt;/strong&gt; — not the date the platform advertises.&lt;/p&gt;

&lt;p&gt;For a traditional broadcaster those are the same day. For a global streaming service they aren't. A TMDB moderator spells it out in their &lt;a href="https://www.themoviedb.org/talk/63eb406c8e870200a988f22d" rel="noopener noreferrer"&gt;"Clarification on Air Date policy" thread&lt;/a&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Amazon Prime release its new content at 0h GMT and Apple TV at 0h Ireland Time on the advertized date, which means that the content is available in the evening of the previous day in the United States. If its a United States show, the recorded date is this previous day and if its an European show, the recorded date is the advertize day."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That last sentence is the part worth reading twice, and the part I initially got wrong.&lt;/p&gt;

&lt;p&gt;The shift is &lt;strong&gt;not a property of the service&lt;/strong&gt;. It depends on the show's &lt;em&gt;origin country&lt;/em&gt;: midnight in Ireland is still the previous evening in Los Angeles, but it's simply midnight in London. So a British show on Apple TV+ is recorded under its advertised date, with no gap at all.&lt;/p&gt;

&lt;p&gt;I checked, because my first version of the fix ignored that clause. &lt;em&gt;Slow Horses&lt;/em&gt; is a British series on Apple TV+: TMDB says 29 October, and so does the press. Shifting that one — as my first attempt would have — would have made it a day late. The same bug pointing the other way.&lt;/p&gt;

&lt;h2&gt;
  
  
  A control case, because one pattern isn't a rule
&lt;/h2&gt;

&lt;p&gt;The obvious risk here is over-correcting: deciding "TMDB is a day early" and shifting everything, which breaks every show that was already right.&lt;/p&gt;

&lt;p&gt;So I checked a show releasing the same week on a different service: &lt;em&gt;Stuart Fails to Save the Universe&lt;/em&gt;, on HBO Max. TMDB said 13 August. Press said 13 August. No gap.&lt;/p&gt;

&lt;p&gt;The discrepancy isn't general to TMDB, or to streaming, or even to every show on these services. It needs both conditions at once: a service that releases globally at a single instant, &lt;strong&gt;and&lt;/strong&gt; an origin country far enough west that the instant lands on the previous local day.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reader's example, and the detail that explains it
&lt;/h2&gt;

&lt;p&gt;After this went up, a commenter pointed out the same thing with the &lt;em&gt;Ted Lasso&lt;/em&gt; season four premiere — a cleaner example than mine, because the advertised date comes from Apple directly.&lt;/p&gt;

&lt;p&gt;TMDB has the episode on &lt;strong&gt;4 August&lt;/strong&gt;. &lt;a href="https://www.apple.com/tv-pr/news/2026/04/apple-tvs-emmy-award-winning-global-smash-hit-series-ted-lasso-returns-for-season-four-on-wednesday-august-5/" rel="noopener noreferrer"&gt;Apple's own press release&lt;/a&gt; says the season returns &lt;strong&gt;Wednesday 5 August&lt;/strong&gt;. It went live at 9pm Eastern on the 4th — the night before the date Apple itself advertised.&lt;/p&gt;

&lt;p&gt;The same commenter mentioned something that turns out to be the root of the whole problem: &lt;strong&gt;TMDB records a date, with no time attached.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's why this can't be resolved inside TMDB. "4 August" is a complete answer if you also know the release happened at 9pm Eastern; it's ambiguous if you don't. A timestamp would let every consumer convert correctly for its own audience. A bare date forces each of them to reconstruct the missing hours — which is exactly the reconstruction this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually did
&lt;/h2&gt;

&lt;p&gt;The app now reads a show's network and its origin country together. The date is shifted forward to the advertised release only when the service is one of these two &lt;strong&gt;and&lt;/strong&gt; the origin country sits behind Irish time — decided by comparing the two timezones, rather than by keeping a list of countries.&lt;/p&gt;

&lt;p&gt;Concretely: &lt;em&gt;Silo&lt;/em&gt; (American) shifts. &lt;em&gt;Slow Horses&lt;/em&gt; (British) doesn't, despite both being Apple TV+ originals released the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you build on TMDB
&lt;/h2&gt;

&lt;p&gt;Three things worth taking away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;air_date&lt;/code&gt; means "earliest availability in the origin country"&lt;/strong&gt;, not "the date this is advertised as". Most of the time there's no difference. For globally-released streaming shows there is, and it will look like your app is broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The correction is narrower than it first appears.&lt;/strong&gt; It would have been easy — and wrong — to shift everything on those two services. Half the fix is knowing when &lt;em&gt;not&lt;/em&gt; to apply it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agreement isn't correctness.&lt;/strong&gt; I fixed a real inconsistency in my own code, the two displays started agreeing with each other, and that felt like success. It was only checking against the outside world — the thing the user did, and I hadn't — that surfaced the actual problem. Then I repeated the mistake in miniature by shipping a network-only rule without checking a British show against it.&lt;/p&gt;

</description>
      <category>api</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>data</category>
    </item>
  </channel>
</rss>
