<?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: Sonny </title>
    <description>The latest articles on DEV Community by Sonny  (@lans-cloud).</description>
    <link>https://dev.to/lans-cloud</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%2F4022432%2F0fbf5089-5198-471f-9bb6-56f9e8f6335c.png</url>
      <title>DEV Community: Sonny </title>
      <link>https://dev.to/lans-cloud</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lans-cloud"/>
    <language>en</language>
    <item>
      <title>Google said my fastest pages were slow: a PageSpeed mystery in three clues</title>
      <dc:creator>Sonny </dc:creator>
      <pubDate>Fri, 10 Jul 2026 08:58:59 +0000</pubDate>
      <link>https://dev.to/lans-cloud/google-said-my-fastest-pages-were-slow-a-pagespeed-mystery-in-three-clues-39o9</link>
      <guid>https://dev.to/lans-cloud/google-said-my-fastest-pages-were-slow-a-pagespeed-mystery-in-three-clues-39o9</guid>
      <description>&lt;p&gt;My site &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt; is 51 static, client-side tools served from one small container. Local Lighthouse scored every page 90+. So when I finally ran the same pages through Google's real PageSpeed Insights (PSI) application programming interface (API), I expected a victory lap.&lt;/p&gt;

&lt;p&gt;Instead I got this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&lt;/th&gt;
&lt;th&gt;Local Lighthouse&lt;/th&gt;
&lt;th&gt;PSI (Google's infra)&lt;/th&gt;
&lt;th&gt;PSI Largest Contentful Paint&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;/classroom-scoreboard&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/my-data&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/spin-the-wheel&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;76&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/classroom-timer&lt;/td&gt;
&lt;td&gt;95&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;79&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/ (homepage)&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;70&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Five pages at 97–100. Five pages at 70–79. Nothing in between — and my local runs had shown &lt;em&gt;no&lt;/em&gt; such split. Every failing page failed the same way: a Largest Contentful Paint (LCP) of ~5.5 seconds.&lt;/p&gt;

&lt;p&gt;Statically prerendered pages. Same layout, same Cascading Style Sheets (CSS), same fonts. A 5.5-second LCP made no sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clue 1: the "slow" element was innocent
&lt;/h2&gt;

&lt;p&gt;PSI's JSON (JavaScript Object Notation) response tells you which element was the LCP. On every slow page it was… the header description. A server-rendered &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt;. Plain text, present in the initial HTML, no image, no font trickery.&lt;/p&gt;

&lt;p&gt;The LCP breakdown said: time to first byte, 14 ms. Element render delay, &lt;strong&gt;2,291 ms&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Static text that the browser has from byte one, painting 2.3 seconds late. The element wasn't slow — something was delaying &lt;em&gt;paint itself&lt;/em&gt;. Which led to the actually useful query: compare &lt;code&gt;observedLargestContentfulPaint&lt;/code&gt; with &lt;code&gt;observedFirstContentfulPaint&lt;/code&gt; — the First Contentful Paint (FCP) — in the raw response.&lt;/p&gt;

&lt;p&gt;They were &lt;strong&gt;identical on every page&lt;/strong&gt;. Fast pages: LCP = FCP = ~140 ms. Slow pages: LCP = FCP = ~2,300 ms.&lt;/p&gt;

&lt;p&gt;So there was never an LCP problem. There was a &lt;em&gt;first paint&lt;/em&gt; problem, and the LCP element was just standing nearby when it happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clue 2: it only happened on slow hardware
&lt;/h2&gt;

&lt;p&gt;From my own machine, real Chrome painted every one of those pages in 100–250 ms. Fast club, slow club, no difference.&lt;/p&gt;

&lt;p&gt;That's the trap with performance work: my hardware is fast, Google's test runners are deliberately slow. Whatever was delaying paint scaled with processor speed — and disappeared entirely when processing power was cheap.&lt;/p&gt;

&lt;p&gt;I diffed everything between a slow page and a fast page: &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; contents, stylesheet lists, script tags, HyperText Markup Language (HTML) byte offsets of the visible text, pending Suspense boundaries. Nearly identical. The slow pages had two extra route-specific JavaScript chunks. That was the whole difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clue 3: the race
&lt;/h2&gt;

&lt;p&gt;Here's the mechanism, and I think it's underdocumented:&lt;/p&gt;

&lt;p&gt;Async scripts don't block parsing — but &lt;strong&gt;evaluating them blocks the main thread&lt;/strong&gt;, and paint needs the main thread. On fast hardware, first paint happens long before any script arrives. On a slow machine with fast-arriving scripts, it's a &lt;em&gt;race&lt;/em&gt;: if a chunk arrives and starts evaluating before the browser gets around to its first paint, paint waits.&lt;/p&gt;

&lt;p&gt;My five slow pages had marginally more JavaScript. Not dramatically more — just enough to tip the race on Google's runners. Five pages painted at 140 ms; five painted after ~2 s of script evaluation.&lt;/p&gt;

&lt;p&gt;Then the second mechanism kicks in: PSI uses &lt;em&gt;simulated&lt;/em&gt; throttling. It records an unthrottled trace and then a model ("Lantern") estimates what the metrics would be on a slow device and network. The model saw "first paint waited for JavaScript" in the trace and, applying its throttling math to that dependency, projected LCP at 5.5 seconds. The 2.3 s race loss became a 5.5 s headline number.&lt;/p&gt;

&lt;p&gt;One trace where paint loses a race by a hair → a model that treats the loss as a structural dependency → a 30-point score gap. That's how five identical-architecture pages split into two clean clubs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was one line
&lt;/h2&gt;

&lt;p&gt;If paint must win a race, shorten paint's track. My CSS totals ~19 kilobytes, but it shipped as two render-blocking stylesheet requests — the largest fixed cost on the paint path. Next.js has a flag for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;standalone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;experimental&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;inlineCss&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;// CSS ships inside the HTML — zero blocking fetches&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 CSS inlined into the HTML, the browser can paint the moment it has parsed the (single) response. No fetches to wait for, nothing for script evaluation to cut in front of.&lt;/p&gt;

&lt;p&gt;Same day, same API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Page&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;/spin-the-wheel&lt;/td&gt;
&lt;td&gt;76&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/classroom-timer&lt;/td&gt;
&lt;td&gt;79&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;92&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/ (homepage)&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;95–99&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/random-name-picker&lt;/td&gt;
&lt;td&gt;74&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;/classroom-scoreboard (control)&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;99&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One caveat from the trenches: the first run right after deploying read &lt;em&gt;lower&lt;/em&gt; (68) — cold caches on Google's side. Re-run before you panic; judge trends, not single runs. And inlining CSS is a trade: it's the right call when your CSS is small (mine is ~19 kilobytes and identical across pages); with hundreds of KB of styles you'd be bloating every HTML response instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Epilogue: the ghost came back — and proved the point
&lt;/h2&gt;

&lt;p&gt;A week later I probed again on an ordinary evening and nearly relapsed into panic: the homepage read 74. But this time I had a &lt;strong&gt;control page&lt;/strong&gt; — one I hadn't deployed to or changed in any way. It read 95 in the afternoon, 78 an hour after that, 73 by night. Same page, same URL, zero deploys. Back-to-back runs of one page gave first paints of 1.2 s and 3.0 s. And the fingerprint from Clue 1 was back on every page: observed LCP exactly equal to observed FCP, with a two-second "element render delay" pinned on an innocent paragraph.&lt;/p&gt;

&lt;p&gt;That's not a website changing three times in an evening — that's shared test runners changing under load. So two rules got added to my monitoring: &lt;strong&gt;probe at a fixed quiet hour&lt;/strong&gt; (mine runs Monday mornings), and &lt;strong&gt;always probe a control page you didn't touch&lt;/strong&gt;. If the control moves with your page, you're measuring Google's infrastructure, not your deploy.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;observedLargestContentfulPaint&lt;/code&gt; vs &lt;code&gt;largestContentfulPaint&lt;/code&gt;&lt;/strong&gt; in the PSI response is the single most diagnostic comparison available. Observed is what happened on their hardware; the other is the model's projection. When they diverge wildly, you're debugging the model's &lt;em&gt;inputs&lt;/em&gt;, not your page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LCP problems are sometimes paint problems, and paint problems are sometimes races.&lt;/strong&gt; If observed LCP equals observed FCP, stop staring at the LCP element.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local Lighthouse and PSI are different instruments.&lt;/strong&gt; My datacenter runner was kinder to the slow pages and harsher on the fast ones — systematically. PSI is the number that correlates with how Google sees you; optimize against it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Get the free API key.&lt;/strong&gt; The keyless PSI endpoint shares one global quota with the entire internet and is permanently exhausted — I verified by firing at the exact moment of quota reset and still getting a 429. With a key it's 25,000 requests per day, which turned "check PSI sometimes" into a weekly probe in my monitoring.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the sequel to &lt;a href="https://dev.to/lans-cloud/51-tools-one-container-build-time-reference-tables-and-a-cls-bug-hunt-11pk"&gt;my earlier Cumulative Layout Shift (CLS) hunt&lt;/a&gt; — same site, same lesson wearing a different costume: the metric that's screaming is rarely the thing that's broken.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt; — 51 free browser tools, all static, all measured. Happy to answer anything about the setup.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>performance</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>51 tools, one container: build-time reference tables and a CLS bug hunt</title>
      <dc:creator>Sonny </dc:creator>
      <pubDate>Thu, 09 Jul 2026 08:49:43 +0000</pubDate>
      <link>https://dev.to/lans-cloud/51-tools-one-container-build-time-reference-tables-and-a-cls-bug-hunt-11pk</link>
      <guid>https://dev.to/lans-cloud/51-tools-one-container-build-time-reference-tables-and-a-cls-bug-hunt-11pk</guid>
      <description>&lt;p&gt;I've spent the last weeks building &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt;: 51 free single-purpose web tools — classroom seating charts, crochet calculators, D&amp;amp;D stat rollers, Minecraft build math. This post is about the three technical decisions that shaped it, and a layout-shift bug hunt that ended somewhere I didn't expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one hard rule: everything computes in the browser
&lt;/h2&gt;

&lt;p&gt;Every tool is client-side. The only server code in the entire project is an anonymous page-view counter (one Postgres upsert) and a feedback endpoint. No accounts, no sessions, and the privacy page can make a claim most sites can't: class lists and student names never reach a server, because there is no code that could receive them.&lt;/p&gt;

&lt;p&gt;The payoff is operational: the whole site is statically prerendered by Next.js and served by one 41 MB container behind Traefik on a small VPS. Deploys are &lt;code&gt;docker compose up --build&lt;/code&gt;. Capacity planning is "the network port saturates before anything else does."&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference tables that cannot drift
&lt;/h2&gt;

&lt;p&gt;Every tool page carries reference content — a Minecraft circle block chart, grading tables, dice probabilities, pomodoro preset math. The rule: &lt;strong&gt;pages import the tool's own logic library and compute their tables at build time.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ReferenceTable&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Classic tabata 20/10 — total time by number of rounds"&lt;/span&gt;
  &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ROUND_COUNTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;rounds&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="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rounds&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;formatTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nf"&gt;totalDuration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;buildSchedule&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;prepare&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="na"&gt;work&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rest&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="nx"&gt;rounds&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="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The table on the page and the timer the visitor runs share one implementation, so they can't disagree. When I added canon Dragon Ball power-level matchups, the lookup throws at build time if a character name doesn't match the data — a typo fails the build instead of publishing a wrong number.&lt;/p&gt;

&lt;p&gt;As a side effect, this is SEO content that's genuinely useful and impossible for a scraper to keep current: it regenerates from code on every build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CLS hunt: 0.71 → 0
&lt;/h2&gt;

&lt;p&gt;Lighthouse flagged Cumulative Layout Shift 0.71 on the busiest page (threshold for "good": 0.10). Three fixes, each revealing the next problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 1 — the skeleton mismatch.&lt;/strong&gt; The interactive tools hydrate behind a Suspense skeleton. The skeletons were ~500px; the hydrated apps were up to 1,186px on mobile. Everything below jumped on hydration. Fix: measure every tool's real hydrated height headlessly at mobile and desktop widths, and put those values as responsive &lt;code&gt;min-height&lt;/code&gt; on the wrapper that survives the swap. Result: … CLS unchanged. Correct fix, wrong culprit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 2 — the ad slot.&lt;/strong&gt; A &lt;code&gt;PerformanceObserver&lt;/code&gt; layout-shift trace on the live page showed the &lt;em&gt;entire app area&lt;/em&gt; moving as one block: the ad slot above it reserved 90px, but the responsive AdSense unit rendered ~250px. Raised the reservation per breakpoint. Result: better, still failing on one page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Round 3 — the real culprit.&lt;/strong&gt; The trace rects made no sense until I measured the &lt;code&gt;&amp;lt;ins&amp;gt;&lt;/code&gt; element directly: &lt;strong&gt;AdSense's script pre-sizes a full-width-responsive unit to roughly viewport-width height — 412px tall on a 412px phone — before any ad even fills.&lt;/strong&gt; No reservation short of the viewport width survives that.&lt;/p&gt;

&lt;p&gt;The documented escape hatch: give the &lt;code&gt;&amp;lt;ins&amp;gt;&lt;/code&gt; an explicit CSS size and remove &lt;code&gt;data-ad-format&lt;/code&gt;/&lt;code&gt;data-full-width-responsive&lt;/code&gt;. AdSense then serves into exactly that box and never resizes it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ins&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"adsbygoogle block h-[250px] w-full max-w-[336px] lg:h-[90px] lg:max-w-[728px]"&lt;/span&gt;
  &lt;span class="na"&gt;data-ad-client&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;data-ad-slot&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;SLOT&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLS after: &lt;strong&gt;0&lt;/strong&gt; on every page I measured. The trade-off is a slightly narrower ad auction than "auto" — but 300×250 is the most-bid display size there is, and CLS is the Core Web Vital that feeds rankings, which feed everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Assorted lessons
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;min-height&lt;/code&gt; reservation is a guess; a CSS-sized ad unit is a contract.&lt;/li&gt;
&lt;li&gt;Measure with &lt;code&gt;PerformanceObserver('layout-shift')&lt;/code&gt; sources on the real page — Lighthouse tells you &lt;em&gt;that&lt;/em&gt; something moved, the sources tell you &lt;em&gt;what&lt;/em&gt; and &lt;em&gt;from where&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;If you pipe &lt;code&gt;git commit&lt;/code&gt; through &lt;code&gt;tail&lt;/code&gt; in a &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; chain, the pipeline's exit code is &lt;code&gt;tail&lt;/code&gt;'s. My pre-commit hook failed silently and I deployed a working tree that git didn't have. Check &lt;code&gt;git log origin/master&lt;/code&gt;, not exit codes.&lt;/li&gt;
&lt;li&gt;Entering browser fullscreen remounts your React tree if the fullscreen branch renders differently. Anything not persisted is gone. Users find this in minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The site is &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt; — all free, feedback button on every page. Happy to answer anything about the setup.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>performance</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
