<?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>Two weeks of Search Console data falsified my SEO playbook</title>
      <dc:creator>Sonny </dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:56:33 +0000</pubDate>
      <link>https://dev.to/lans-cloud/two-weeks-of-search-console-data-falsified-my-seo-playbook-51if</link>
      <guid>https://dev.to/lans-cloud/two-weeks-of-search-console-data-falsified-my-seo-playbook-51if</guid>
      <description>&lt;p&gt;I run &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt;, a collection of 100 free single-purpose browser tools (classroom timers, name pickers, calculators, generators). The SEO playbook I launched with was the standard indie one: target specific long-tail phrases, build deep topic clusters, interlink everything, wait.&lt;/p&gt;

&lt;p&gt;The site is young enough that I recently had exactly ~14 days of real Search Console data. I did a deep analysis run over all of it — every query, every page — expecting to confirm the playbook. Instead the data falsified almost every rule I was following. Here are the five lessons, with the actual numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Your page-level averages are two populations wearing a coat
&lt;/h2&gt;

&lt;p&gt;Search Console only attributes impressions to a query string when enough people search it — rare queries fall below a privacy floor and become invisible. On my site, &lt;strong&gt;only 52.6% of impressions had a named query&lt;/strong&gt;. So I split every page's stats into a &lt;em&gt;named&lt;/em&gt; slice and a &lt;em&gt;hidden&lt;/em&gt; (anonymized) slice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Slice&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;th&gt;CTR&lt;/th&gt;
&lt;th&gt;Avg position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Named queries&lt;/td&gt;
&lt;td&gt;2,767&lt;/td&gt;
&lt;td&gt;293&lt;/td&gt;
&lt;td&gt;10.6%&lt;/td&gt;
&lt;td&gt;29.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hidden queries&lt;/td&gt;
&lt;td&gt;2,489&lt;/td&gt;
&lt;td&gt;105&lt;/td&gt;
&lt;td&gt;4.2%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13.3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The hidden slice ranks &lt;strong&gt;16 positions better&lt;/strong&gt; and converts &lt;strong&gt;2.5× worse&lt;/strong&gt; — and that inversion held across 24 separate pages.&lt;/p&gt;

&lt;p&gt;Why this matters: one of my pages showed &lt;em&gt;555 impressions at position ~9 with zero clicks&lt;/em&gt;. Every SEO checklist says that's a title/snippet problem, so I rewrote the title. Nothing. A second rewrite was queued when the split showed what was actually happening: the page's &lt;em&gt;named&lt;/em&gt; queries sat at position 34.8 (page 3–4, where zero clicks is the correct number), while 522 hidden impressions sat at position 7.7 — roughly 500 near-unique long questions hitting a big FAQ block, each answered directly &lt;em&gt;in the snippet&lt;/em&gt;. Google was serving my content without the visit. No title rewrite can fix "the SERP already answered the question."&lt;/p&gt;

&lt;p&gt;I could verify it because the site keeps a privacy-preserving aggregate referrer counter server-side: on pages Search Console says convert, the two instruments agree almost exactly (39 clicks vs 39 visits on one page). On the "position 9" page: 3 visits. The zero was real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; before you rewrite a title over "great position, no clicks," split named vs hidden queries (pull the query-dimension total and subtract it from the page-dimension total). And be suspicious of building pages whose entire payoff fits inside a featured snippet.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Content depth predicted nothing
&lt;/h2&gt;

&lt;p&gt;The playbook says deep clusters rank. My deepest cluster — 29 classroom tools, its own hub page, dense internal linking — sat at &lt;strong&gt;median position 37&lt;/strong&gt;. My shallowest real cluster — 4 Dragon Ball Z toys — sat at &lt;strong&gt;median position 7&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The obvious objections all failed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"It's one breakout page."&lt;/em&gt; Remove the best page from the winning cluster entirely and its remaining 9 tools still return 6.6% CTR — still the best cluster.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"The deep cluster is older/newer."&lt;/em&gt; My oldest cohort (14–16 days indexed) had 2,645 impressions and &lt;strong&gt;31 clicks&lt;/strong&gt; at median position 32. The newest cohort (≤9 days) had 2,409 impressions and &lt;strong&gt;364 clicks&lt;/strong&gt; at median position 19. The &lt;em&gt;newest&lt;/em&gt; pages outranked the oldest by 13 positions and out-clicked them 12:1. A 16-day-old page at position 68 with a 9-day-old sibling at position 9 has been evaluated and placed — it is not "warming up."&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"The losing queries aren't long-tail enough."&lt;/em&gt; Average words per query was flat at 3.3–3.7 across every cluster, winners and losers alike. Phrase length discriminated nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. The variable that actually predicted rank: incumbents
&lt;/h2&gt;

&lt;p&gt;What separated the winning clusters from the losing ones was visible on the SERP before I ever built the pages — I just never looked.&lt;/p&gt;

&lt;p&gt;The winning family targets an intent no brand owns. No Wikipedia entry, no YouTube dominance, no decade-old exact-match domain — page one is a scatter of small sites, so a good page can actually place. The losing clusters compete with entrenched tools (and worse: a chunk of their impression volume turned out to be &lt;em&gt;navigational&lt;/em&gt; — people typing a competitor's brand name. You will never be a better answer to &lt;code&gt;&amp;lt;brand&amp;gt; name picker&lt;/code&gt; than the brand itself, and those impressions inflate your dashboard while converting nothing).&lt;/p&gt;

&lt;p&gt;The cruelest illustration: my &lt;a href="https://lans.cloud/roman-numeral-converter" rel="noopener noreferrer"&gt;Roman numeral converter&lt;/a&gt; became the site's third-biggest page by impressions, surfacing for ~60 phrasing variants — at &lt;strong&gt;position 62, with 0 clicks&lt;/strong&gt;. Meanwhile the winning family had ~40 phrasing variants of its intent at position ~7, converting at 16%. Same wide phrasing family, opposite outcomes. &lt;strong&gt;Variant explosion is an amplifier whose sign is set by your rank.&lt;/strong&gt; A big impression count from many phrasings is not validation — check the position first.&lt;/p&gt;

&lt;p&gt;My screening test now, before building anything: &lt;em&gt;search the target phrase and look at who owns page one.&lt;/em&gt; Brand, Google widget, or ancient exact-match domain → don't build it (or accept that only backlinks/authority can move it, not content). Scatter of small sites → build.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Aggregate dashboards lie by mixture
&lt;/h2&gt;

&lt;p&gt;My site-wide CTR "improved" from 5.5% to 8.6% in two weeks. Celebration? No — decomposition. The site is a two-population mixture: one viral-ish page at 20.6% CTR, everything else at 2.21%. Solving the mixture showed the entire "improvement" was the big page's impression share nearly doubling (17.9% → 34.7%). CTR at constant position improved nowhere.&lt;/p&gt;

&lt;p&gt;The concentration behind that: one page held &lt;strong&gt;77.9% of all clicks&lt;/strong&gt; (click Gini coefficient: 0.978, computed for fun and regret), and 98 of 118 indexed pages had zero. If you only track the site-level line in Search Console, mix shift will masquerade as progress in both directions. Decompose before you celebrate — Simpson's paradox is alive and running an SEO dashboard near you.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Google picks one page per intent — and it may not pick the one you optimized
&lt;/h2&gt;

&lt;p&gt;I worried about keyword cannibalization; the data says it barely exists (4 of 675 queries mapped to two of my pages). But the one real case was instructive: I have a straight &lt;a href="https://lans.cloud/coin-flip" rel="noopener noreferrer"&gt;coin flip&lt;/a&gt; page and a &lt;a href="https://lans.cloud/rigged-coin-flip" rel="noopener noreferrer"&gt;rigged coin flip&lt;/a&gt; page. For the generic query &lt;code&gt;flip a coin&lt;/code&gt;, Google ranks the &lt;em&gt;rigged&lt;/em&gt; page at position 11 and the generic page at position 83 — for identical query sets, a 60-position gap. The specific, personality-having page won the generic term. The generic page built "for SEO" got nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Split named vs hidden queries before diagnosing any page.&lt;/li&gt;
&lt;li&gt;Don't build for a SERP you haven't looked at. The incumbent test takes 30 seconds and predicts more than any keyword metric.&lt;/li&gt;
&lt;li&gt;Depth multiplies rank on open SERPs and multiplies zero on owned ones. Same energy, route it by SERP.&lt;/li&gt;
&lt;li&gt;Never trust an aggregate trend you haven't decomposed.&lt;/li&gt;
&lt;li&gt;Wide phrasing families are amplifiers, not validation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All of this came from a site you can poke at — the tools live at &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt;, the classroom set (the humbled cluster of lesson 2) is at &lt;a href="https://lans.cloud/classroom-tools" rel="noopener noreferrer"&gt;lans.cloud/classroom-tools&lt;/a&gt;. Earlier posts in this series: &lt;a href="https://dev.to/lans-cloud/51-tools-one-container-build-time-reference-tables-and-a-cls-bug-hunt-11pk"&gt;the architecture&lt;/a&gt; and &lt;a href="https://dev.to/lans-cloud/my-marketing-docs-kept-lying-so-i-put-them-in-ci-0"&gt;putting marketing docs in CI&lt;/a&gt;. Happy to share the analysis scripts if anyone wants them.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>My marketing docs kept lying, so I put them in CI</title>
      <dc:creator>Sonny </dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:50:29 +0000</pubDate>
      <link>https://dev.to/lans-cloud/my-marketing-docs-kept-lying-so-i-put-them-in-ci-0</link>
      <guid>https://dev.to/lans-cloud/my-marketing-docs-kept-lying-so-i-put-them-in-ci-0</guid>
      <description>&lt;p&gt;My side project &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt; has 100 free browser tools. For three weeks, everything I posted about it — the launch article, the directory listings, the pitch emails — said 37. Then 50. Then 51. Then 50 again, because 51 had been wrong.&lt;/p&gt;

&lt;p&gt;Code has tests. Docs drift. And marketing collateral drifts &lt;em&gt;worst&lt;/em&gt;, because nothing executes it: a wrong number in a pitch email doesn't throw, it just quietly makes you look sloppy in front of the one blogger you wanted to impress.&lt;/p&gt;

&lt;p&gt;So I started treating marketing bookkeeping the way I treat code: continuous integration (CI) for prose — a check that runs on every ship. It's about eighty lines, and it has already caught real mistakes I'd missed by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The facts checker that blocks &lt;code&gt;git push&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The recurring bug: my tool count appears in a launch kit, a founder Q&amp;amp;A, standard directory blurbs, social post drafts. Every new tool silently invalidates all of them. Grepping and fixing by hand worked exactly as well as you'd expect — I missed a "forty-one free tools" claim hiding in a directory blurb for weeks.&lt;/p&gt;

&lt;p&gt;The fix is a ~80-line script that derives the true count from the code and refuses to ship while any living marketing document disagrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The registry is the single source of truth…&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allToolsSection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export const ALL_TOOLS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;liveCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;allToolsSection&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="sr"&gt;/status: 'live'/g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// …and any "&amp;lt;N&amp;gt; … tools" phrase in living marketing docs must match it.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;COUNT_RE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(\d{2,3})\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(?:(?:&lt;/span&gt;&lt;span class="sr"&gt;live|free|single-purpose|browser&lt;/span&gt;&lt;span class="se"&gt;(?:&lt;/span&gt;&lt;span class="sr"&gt;-based&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;|web&lt;/span&gt;&lt;span class="se"&gt;)\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;){0,4}&lt;/span&gt;&lt;span class="sr"&gt;tools&lt;/span&gt;&lt;span class="se"&gt;?\b&lt;/span&gt;&lt;span class="sr"&gt;/gi&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's wired into the ship script, before the push:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ship.sh&lt;/span&gt;
node scripts/check-marketing-facts.mjs   &lt;span class="c"&gt;# exits 1 on drift&lt;/span&gt;
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One design decision matters more than the code:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Historical documents are exempt.&lt;/strong&gt; Sent emails, archived posts, old status logs — those keep their numbers. A pitch email I sent when there were thirty-seven tools &lt;em&gt;should&lt;/em&gt; say thirty-seven forever. Only the living documents (the ones I'll copy from tomorrow) are checked.&lt;/p&gt;

&lt;p&gt;The checker caught a stale "forty-one" on its very first run, and a batch of wrong counts across sixteen locations on its second day. It has paid for itself several times over.&lt;/p&gt;

&lt;p&gt;(You may notice the historical counts in this post are spelled out as words. That is not a style choice: the checker scans my &lt;em&gt;drafts&lt;/em&gt; too, and it flagged this very article for containing old numbers next to the word "tools". I could add an ignore-marker — or I could let the tooling win. The tooling won.)&lt;/p&gt;

&lt;p&gt;Marketing collateral is code that executes in other people's heads. Test it like it matters.&lt;/p&gt;

&lt;p&gt;The site is &lt;a href="https://lans.cloud" rel="noopener noreferrer"&gt;lans.cloud&lt;/a&gt;; the first article about its architecture is &lt;a href="https://dev.to/lans-cloud/51-tools-one-container-build-time-reference-tables-and-a-cls-bug-hunt-11pk"&gt;here&lt;/a&gt;. Happy to share any of the scripts in full.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>automation</category>
      <category>testing</category>
      <category>sideprojects</category>
    </item>
    <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>
