<?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>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>
